PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.0.1
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.0.1
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / render / chromeless-bridge.php

chromeless-bridge.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.0.1, at includes/render/chromeless-bridge.php

3,730 lines 134.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Chromeless iframe bridge.
4 *
5 * Two cooperative pieces emitted into chromeless admin pages:
6 *
7 * - `openstation_chromeless_offset_neutralizer_script()` —
8 * runs on `admin_head @ 1` and rewrites positioned-element
9 * `top` values that match common admin-bar offsets (32px /
10 * 46px) to 0 inside chromeless iframes. Catches plugins that
11 * hardcode the admin-bar height instead of using the WP CSS
12 * custom property.
13 *
14 * - `openstation_chromeless_bridge_script()` — runs on
15 * `admin_footer` and emits the chromeless ↔ shell bridge
16 * script that handles screen-meta detection, command-palette
17 * harvesting, plugin-changed payloads, etc. The biggest
18 * hook in the original render.php (~1,950 LOC) — the bulk is
19 * the inline JS string the iframe runs.
20 *
21 * Extracted from `render.php` during the architecture-0.8.1 PHP
22 * slicing (phase 6).
23 *
24 * @package OpenStation
25 */
26
27 defined( 'ABSPATH' ) || exit;
28
29
30 /**
31 * Neutralizes hardcoded admin-bar offsets on positioned elements
32 * inside chromeless iframes.
33 *
34 * Many plugins compile their CSS with the admin-bar height baked in
35 * as a literal pixel value rather than referencing
36 * `var(--wp-admin--admin-bar--height)`. WooCommerce's
37 * `.woocommerce-layout__header` is the canonical case — it ships as
38 * `top: 32px` (or `46px` on small screens) because the SCSS source
39 * uses build-time interpolation (`#{$header-height + $adminbar-height-mobile}`).
40 * A CSS-variable rebind cannot reach these rules because the rules
41 * never read the variable.
42 *
43 * The only generic mitigation is a runtime DOM pass:
44 *
45 * 1. Walk every positioned element (`fixed | sticky | absolute`).
46 * 2. Compare its computed `top` against the set of values that
47 * reserve admin-bar height (defaults: `32px`, `46px`).
48 * 3. If it matches, override `top` to `0` inline with `!important`.
49 *
50 * The match is exact-pixel — we deliberately don't catch e.g.
51 * `top: 33px` (which is almost certainly intentional and unrelated
52 * to admin-bar geometry). False positives are possible but
53 * unlikely; a plugin would have to use `top: 32px` for a reason
54 * unrelated to the admin bar AND need that exact value to remain
55 * inside chromeless. We've never seen one in the wild, and if a
56 * site hits it, the filter below lets them narrow the scan.
57 *
58 * Scoped via the `os-chromeless` body class. Runs ONE
59 * full walk at DOMContentLoaded, then watches for late additions
60 * with a `MutationObserver` so React-mounted components are
61 * corrected as they appear instead of via a second full-DOM walk
62 * at `load`. The observer only inspects added nodes, not the
63 * whole document, which is roughly two orders of magnitude
64 * cheaper than the old double-walk on a busy Gutenberg or
65 * WooCommerce admin page (~2,000+ `getComputedStyle()` calls
66 * collapsed into a one-time initial walk plus per-addition
67 * checks).
68 *
69 * Fallback for very old browsers without `MutationObserver`:
70 * keep the second walk at `load`. The current minimum (IE 11+)
71 * already ships MO, so the fallback only fires on extreme
72 * outliers — but it's free insurance.
73 */
74 function openstation_chromeless_offset_neutralizer_script() {
75 if ( ! openstation_is_chromeless_request() ) {
76 return;
77 }
78
79 /**
80 * Filters the set of `top` pixel values that mark a positioned
81 * element as an admin-bar offset clone.
82 *
83 * Defaults match the two admin-bar heights Core ships: `32px`
84 * for desktop, `46px` for the mobile breakpoint. Sites that
85 * customize the admin bar height (some accessibility themes
86 * raise it to 50px) can extend the list.
87 *
88 * @param string[] $values Default `[ '32px', '46px' ]`.
89 */
90 $top_values = apply_filters(
91 'openstation_chromeless_admin_bar_top_values',
92 array( '32px', '46px' )
93 );
94
95 $config = wp_json_encode(
96 array(
97 'tops' => array_values( array_filter( array_map( 'strval', (array) $top_values ) ) ),
98 )
99 );
100 if ( false === $config ) {
101 return;
102 }
103
104 // Build the inline JS as a concatenated single-quoted string —
105 // Plugin Check disallows heredoc syntax (PluginCheck.CodeAnalysis.
106 // Heredoc.NotAllowed), so the source is uglier than the original
107 // `<<<JS … JS;` block but functionally identical. The trailing
108 // `$config` JSON is appended at the end so the whole body is a
109 // closure receiving a `{tops: [...]}` argument.
110 $js = '(function(C){';
111 $js .= 'var TOPS={};';
112 $js .= 'for(var t=0;t<C.tops.length;t++){TOPS[C.tops[t]]=1;}';
113 $js .= 'function fixOne(el){';
114 $js .= 'if(!el||el.nodeType!==1)return;';
115 $js .= 'var cs;';
116 $js .= 'try{cs=getComputedStyle(el);}catch(_e){return;}';
117 $js .= "if(cs.position==='static')return;";
118 $js .= "if(TOPS[cs.top]){el.style.setProperty('top','0px','important');}";
119 $js .= '}';
120 $js .= 'function walkSubtree(root){';
121 $js .= 'if(!root)return;';
122 $js .= 'if(root.nodeType===1){fixOne(root);}';
123 $js .= "var els=root.querySelectorAll?root.querySelectorAll('*'):[];";
124 $js .= 'for(var i=0;i<els.length;i++){fixOne(els[i]);}';
125 $js .= '}';
126 $js .= 'var started=false;';
127 $js .= 'function start(){';
128 $js .= 'if(started)return;';
129 $js .= "if(!document.body||!document.body.classList.contains('os-chromeless'))return;";
130 $js .= 'started=true;';
131 $js .= 'var MO=window.MutationObserver;';
132 $js .= 'if(MO){';
133 $js .= 'var observer=new MO(function(records){';
134 $js .= 'for(var r=0;r<records.length;r++){';
135 $js .= 'var rec=records[r];';
136 $js .= "if(rec.type!=='childList')continue;";
137 $js .= 'var added=rec.addedNodes;';
138 $js .= 'for(var n=0;n<added.length;n++){walkSubtree(added[n]);}';
139 $js .= '}';
140 $js .= '});';
141 $js .= 'observer.observe(document.body,{childList:true,subtree:true});';
142 $js .= '}';
143 $js .= 'walkSubtree(document.body);';
144 // Defense in depth — pre-MutationObserver browsers fall back to the
145 // original double-walk so React-mounted components added between
146 // DOMContentLoaded and load still get neutralized.
147 $js .= 'if(!MO){';
148 $js .= "window.addEventListener('load',function(){walkSubtree(document.body);},{once:true});";
149 $js .= '}';
150 $js .= '}';
151 $js .= "if(document.readyState==='loading'){";
152 $js .= "document.addEventListener('DOMContentLoaded',start,{once:true});";
153 $js .= '}else{';
154 $js .= 'start();';
155 $js .= '}';
156 $js .= '})(' . $config . ');';
157
158 wp_print_inline_script_tag( $js );
159 }
160 add_action( 'admin_head', 'openstation_chromeless_offset_neutralizer_script', 1 );
161
162 /**
163 * Short-circuit `admin.php?openstation_menu_refresh=1` requests with
164 * a tiny inline-script response that postMessages the current menu
165 * payload to the parent shell.
166 *
167 * The full chromeless bridge is hooked on `admin_footer`, which Core
168 * only fires from `admin-header.php` / `admin-footer.php`. Plain
169 * `admin.php` without `?page=` (or one of the other dispatch paths
170 * in admin.php) never includes the footer — the file just runs the
171 * `load-{$pagenow}` hook in the `else` branch and exits. The full
172 * bridge therefore never emits its payload, and the parent's
173 * `wp.os.refreshMenu()` waits out its 8-second timeout for a
174 * message that's never coming. That's the source of "deactivating a
175 * plugin leaves its dock icons behind" — the hidden probe iframe
176 * the shell spawns to harvest the post-mutation menu lands on a
177 * page that doesn't fire admin_footer.
178 *
179 * Hooking here on `admin_init @ 99` runs AFTER `wp-admin/menu.php`
180 * has loaded (which fires `admin_menu` and populates `$menu`) but
181 * BEFORE admin.php's per-page dispatch. We can emit the payload
182 * straight away and short-circuit the rest of admin.php so the probe
183 * resolves in milliseconds instead of timing out.
184 *
185 * No admin-header / admin-footer means no `#adminmenu` DOM, so the
186 * full bridge's CSS-icon harvest doesn't run here. That's an
187 * acceptable trade-off: items whose icons live in `$menu[$i][6]`
188 * (the vast majority) still ship correctly; items that rely on a
189 * CSS `::before` on `#adminmenu .menu-icon-<slug>` fall back to the
190 * default gear icon on a live refresh until the next full page load
191 * — strictly better than today's "dock doesn't update at all."
192 */
193 function openstation_emit_menu_refresh_probe() {
194 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only payload harvest; capability-gated by chromeless gate below.
195 if ( empty( $_GET['openstation_menu_refresh'] ) ) {
196 return;
197 }
198 if ( ! openstation_is_chromeless_request() ) {
199 return;
200 }
201 // Only short-circuit the bare `admin.php` probe — for any real
202 // admin page (plugins.php, edit.php, etc.) we still want the full
203 // admin-footer-hosted bridge to fire so the icon harvest runs.
204 $pagenow = isset( $GLOBALS['pagenow'] ) ? (string) $GLOBALS['pagenow'] : '';
205 if ( 'admin.php' !== $pagenow ) {
206 return;
207 }
208
209 $payload = openstation_build_menu_payload();
210 $encoded = wp_json_encode( $payload );
211 if ( false === $encoded ) {
212 return;
213 }
214
215 nocache_headers();
216 header( 'Content-Type: text/html; charset=utf-8' );
217
218 // Mirror the full bridge's message shape so the same shell-side
219 // listener consumes both.
220 echo '<!doctype html><html><head><meta charset="utf-8"><title></title></head><body>';
221 echo '<script>';
222 echo '(function(){try{if(window.parent&&window.parent!==window){window.parent.postMessage({type:"os-plugins-changed",payload:';
223 echo $encoded; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode produces JSON-safe output.
224 echo '},window.location.origin);}}catch(e){}})();';
225 echo '</script>';
226 echo '</body></html>';
227 exit;
228 }
229 add_action( 'admin_init', 'openstation_emit_menu_refresh_probe', 99 );
230
231 /**
232 * Outputs the chromeless screen-meta bridge script.
233 *
234 * Detects Screen Options / Help panels in the iframed page and relays
235 * their availability + open/closed state to the parent desktop shell
236 * via postMessage. The parent shell uses this to render matching
237 * buttons in the window title bar.
238 */
239 function openstation_chromeless_bridge_script() {
240 if ( ! openstation_is_chromeless_request() ) {
241 return;
242 }
243
244 /**
245 * Fires after chromeless content in OpenStation.
246 *
247 * @param string $hook_suffix The current admin page hook suffix.
248 */
249 do_action( 'openstation_chromeless_after', isset( $GLOBALS['hook_suffix'] ) ? $GLOBALS['hook_suffix'] : '' );
250
251 // Menu payload — built from the LIVE $menu / $submenu globals
252 // populated by real admin-context bootstrapping. We capture it here
253 // rather than making the parent refetch via REST because many
254 // plugins evaluate `is_admin()` at plugin-file-load time and only
255 // register their `admin_menu` hook when it returns true; in a REST
256 // context `WP_ADMIN` isn't defined at load, so those plugins never
257 // hook in and their menu entries are missing from any endpoint we
258 // could expose. Here we're INSIDE an admin request (plugins.php,
259 // plugin-install.php, update.php, themes.php) where every plugin's
260 // menu registered normally, so `$menu` carries the authoritative
261 // post-activation state.
262 //
263 // Narrowed to the set of pages whose completion commonly mutates
264 // the admin menu (activation / deactivation / install / theme
265 // switch), plus the explicit `openstation_menu_refresh=1` signal
266 // the shell sets when `wp.os.refreshMenu()` spawns a hidden
267 // iframe to harvest a fresh payload from real admin context.
268 // Navigating to edit.php or similar doesn't change the menu so we
269 // don't bother sending a payload otherwise — the debounce +
270 // idempotent replaceItems on the parent side would still make it
271 // safe, just wasteful.
272 $menu_payload_json = 'null';
273 $pagenow = isset( $GLOBALS['pagenow'] ) ? (string) $GLOBALS['pagenow'] : '';
274 $is_refresh_probe = ! empty( $_GET['openstation_menu_refresh'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only payload harvest, capability-gated by the host admin page.
275 if (
276 $is_refresh_probe
277 || in_array(
278 $pagenow,
279 array( 'plugins.php', 'plugin-install.php', 'update.php', 'themes.php' ),
280 true
281 )
282 ) {
283 $encoded = wp_json_encode( openstation_build_menu_payload() );
284 if ( false !== $encoded ) {
285 $menu_payload_json = $encoded;
286 }
287 }
288
289 // Content identity — which object this admin page shows ("comment 45
290 // of post 123"). Built here, in real admin context, because the URL
291 // alone can't resolve relations like comment → parent post. Always
292 // emitted (including `null`) so navigating an iframe from an
293 // identified page to an unidentified one clears the stale identity
294 // in the parent's relations engine.
295 $content_identity_json = wp_json_encode( openstation_build_content_identity() );
296 if ( false === $content_identity_json ) {
297 $content_identity_json = 'null';
298 }
299
300 // Emit via wp_print_inline_script_tag so CSP nonces and `<script>`
301 // attribute hygiene go through Core rather than being hand-rolled.
302 $js = <<<'JS'
303 //# sourceURL=os-chromeless-bridge.js
304 ( function() {
305 // Escape hatch: a chromeless page is *normally* only meant to live
306 // inside a openstation window iframe. If the top window IS this
307 // page, the user usually ended up here by accident — bookmarked
308 // it, followed a stale link, or got stranded by a bad portal
309 // redirect. Without an admin bar there's no toggle to turn
310 // OpenStation off, so strip the chromeless flag and reload as
311 // classic admin. That puts the admin bar back and lets the user
312 // decide what to do.
313 //
314 // Unless something is deliberately HOSTING this page top-level.
315 // `window.openStationChromelessHost` is how an embedder says "this
316 // is not an accident, and I provide the way out" — the native
317 // desktop host sets it on windows a user set free, where closing
318 // the OS window is the way back. Rescuing those would strip the
319 // flag, reload as classic, bounce through the portal, and leave a
320 // whole second desktop inside a window that was meant to hold one
321 // screen.
322 //
323 // It has to be a JS global rather than a query flag: a query flag
324 // is lost on the first in-page navigation, and the host would stop
325 // recognising its own window the moment the user clicked a link.
326 if ( ! window.parent || window.parent === window ) {
327 if ( ! window.openStationChromelessHost ) {
328 try {
329 var here = new URL( window.location.href );
330 if ( here.searchParams.has( 'openstation_chromeless' ) ) {
331 here.searchParams.delete( 'openstation_chromeless' );
332 here.searchParams.delete( 'desktop_mode_portal' );
333 window.location.replace( here.toString() );
334 }
335 } catch ( err ) {
336 /* URL parse failure — let the broken state stand rather than
337 * navigate somewhere worse. */
338 }
339 }
340 // Either way the rest of the bridge is skipped: every feature
341 // below posts to `window.parent`, and there isn't one.
342 return;
343 }
344
345 /*
346 * Content-identity announcement. The server resolved which object
347 * this page shows (post / comment / attachment, plus the root post
348 * a child belongs to) while it still had real admin context; hand
349 * it to the parent's relations engine. Deliberately posted even
350 * when the identity is null — a full-page navigation away from an
351 * identified screen must CLEAR the stale identity, and every
352 * navigation re-runs admin_footer, so this doubles as the
353 * re-announce-on-navigate path.
354 *
355 * Posted FIRST, right after the top-frame escape hatch, because it
356 * depends on nothing else in this script: a page-specific runtime
357 * failure in any of the feature blocks below (screen-meta harvest,
358 * command scan, link interceptor, …) must not cost the shell its
359 * window relations. The `os-ready` signal intentionally
360 * stays LAST — it means "every listener below is wired".
361 */
362 try {
363 window.parent.postMessage(
364 {
365 type: 'os-content-identity',
366 identity: /*__OPENSTATION_CONTENT_IDENTITY__*/
367 },
368 window.location.origin
369 );
370 } catch ( _err ) { /* parent gone or cross-origin */ }
371
372 /*
373 * Editor save-watcher — keeps the identity fresh across block-editor
374 * saves. Gutenberg saves over REST without a page navigation, so the
375 * announcement above (rebuilt only on admin_footer) goes stale the
376 * moment the user adds a category, links a post, or sets a featured
377 * image — the parent's Related menu and window ties would show the
378 * pre-save state until a manual reload. After every real
379 * (non-autosave) save completes, refetch a server-recomputed
380 * identity from `desktop-mode/v1/content-identity` and re-announce
381 * it; the parent engine diffs and repaints. The classic editor
382 * reloads the page on save, which re-runs the announcement
383 * naturally — this block never engages there (no `core/editor`
384 * store on the page).
385 */
386 window.addEventListener( 'load', function () {
387 try {
388 var wpg = window.wp;
389 if ( ! wpg || ! wpg.data || ! wpg.apiFetch || typeof wpg.data.select !== 'function' ) {
390 return;
391 }
392 var editor = wpg.data.select( 'core/editor' );
393 if (
394 ! editor ||
395 typeof editor.isSavingPost !== 'function' ||
396 typeof editor.getCurrentPostId !== 'function'
397 ) {
398 return;
399 }
400 var wasSaving = false;
401 var wasNew = false;
402 var inFlight = false;
403 wpg.data.subscribe( function () {
404 var saving =
405 editor.isSavingPost() &&
406 ! ( editor.isAutosavingPost && editor.isAutosavingPost() );
407 if ( saving && ! wasSaving ) {
408 // Capture "is this the first real save?" on the tick
409 // where saving STARTS — after the save completes the
410 // post is no longer new and the flag reads false.
411 wasNew = !! (
412 editor.isEditedPostNew && editor.isEditedPostNew()
413 );
414 }
415 var finished = wasSaving && ! saving;
416 wasSaving = saving;
417 if ( ! finished || inFlight ) {
418 return;
419 }
420 if (
421 editor.didPostSaveRequestSucceed &&
422 ! editor.didPostSaveRequestSucceed()
423 ) {
424 return;
425 }
426 var postId = editor.getCurrentPostId();
427 if ( ! postId ) {
428 return;
429 }
430
431 /*
432 * Announce the save as a cross-window content-change
433 * broadcast. Gutenberg saves over REST with no
434 * navigation, so the server-side chromeless-footer
435 * emitter (includes/content-changes.php) never runs
436 * here — this is the only instant path for block-editor
437 * saves. The parent's broadcast receiver fans it out;
438 * list windows showing this post type refresh.
439 */
440 if ( editor.getCurrentPostType ) {
441 try {
442 window.parent.postMessage(
443 {
444 type: 'os-broadcast',
445 topic:
446 'os.' +
447 editor.getCurrentPostType() +
448 '.changed',
449 payload: {
450 source: 'editor',
451 action: wasNew ? 'created' : 'updated',
452 ids: [ postId ],
453 },
454 },
455 window.location.origin
456 );
457 } catch ( _err ) { /* parent gone */ }
458 }
459 inFlight = true;
460 wpg
461 .apiFetch( {
462 path: '/desktop-mode/v1/content-identity?post=' + postId,
463 } )
464 .then( function ( res ) {
465 if ( res && res.identity ) {
466 window.parent.postMessage(
467 {
468 type: 'os-content-identity',
469 identity: res.identity,
470 },
471 window.location.origin
472 );
473 }
474 } )
475 .catch( function () {
476 /* Transient — the next save retries. */
477 } )
478 .finally( function () {
479 inFlight = false;
480 } );
481 } );
482 } catch ( _err ) {
483 /* Editor stores absent or shaped differently — nothing to watch. */
484 }
485 } );
486
487 /*
488 * Observability — iframe error + network capture.
489 *
490 * Everything admin-interesting (REST failures from Gutenberg,
491 * admin-ajax 500s, plugin console warnings) fires INSIDE the
492 * iframe whose parent is the desktop shell. Without relaying
493 * those events to the shell, monitor / debug widgets would only
494 * ever see the shell's own errors — the smallest, least-
495 * interesting surface in the whole admin.
496 *
497 * Two listeners and two wrappers land here:
498 *
499 * - `error` + `unhandledrejection` on window → postMessage
500 * `os-iframe-error`. Parent dispatches `HOOKS.
501 * IFRAME_ERROR`.
502 * - `fetch` + `XMLHttpRequest` are wrapped so every completed
503 * request (including failures) posts
504 * `os-iframe-network` with `{ method, url, status,
505 * duration, failed }`. Parent dispatches `HOOKS.
506 * IFRAME_NETWORK_COMPLETED`.
507 *
508 * Privacy: request / response bodies are NEVER captured — only
509 * method, URL, status, duration. Monitor widgets that want the
510 * full payload must ship their own deeper wrapper (at which
511 * point they own the consent conversation).
512 */
513 try {
514 window.addEventListener( 'error', function ( e ) {
515 try {
516 window.parent.postMessage( {
517 type: 'os-iframe-error',
518 kind: 'error',
519 message: e && e.message ? String( e.message ) : '',
520 filename: e && e.filename ? String( e.filename ) : null,
521 lineno: e && typeof e.lineno === 'number' ? e.lineno : null,
522 colno: e && typeof e.colno === 'number' ? e.colno : null,
523 stack: e && e.error && e.error.stack ? String( e.error.stack ) : null
524 }, window.location.origin );
525 } catch ( _err ) { /* swallow: don't let the relay compound the error */ }
526 } );
527
528 window.addEventListener( 'unhandledrejection', function ( e ) {
529 try {
530 var reason = e && 'reason' in e ? e.reason : null;
531 var message = '';
532 var stack = null;
533 if ( reason instanceof Error ) {
534 message = reason.message;
535 stack = reason.stack || null;
536 } else if ( reason !== null && reason !== undefined ) {
537 try { message = String( reason ); } catch ( _s ) { message = '[unstringifiable]'; }
538 }
539 window.parent.postMessage( {
540 type: 'os-iframe-error',
541 kind: 'unhandledrejection',
542 message: message,
543 filename: null,
544 lineno: null,
545 colno: null,
546 stack: stack
547 }, window.location.origin );
548 } catch ( _err ) { /* swallow */ }
549 } );
550
551 // Devtools instrumentation slot — populated by
552 // `os-instrument-set` messages from the parent shell.
553 // Mutable: parent overwrites the whole object on every change
554 // (header add/remove, observe toggle).
555 //
556 // Headers: { name: 'value' } — already pre-merged by the parent
557 // (RFC 7230 §3.2.2 join applied there).
558 // Observe: when true, network reports include request +
559 // response headers; otherwise only the privacy-conscious
560 // summary travels parent-bound.
561 window.__wpdInstrument = window.__wpdInstrument || { headers: {}, observe: false };
562 try {
563 window.addEventListener( 'message', function ( ev ) {
564 if ( ev.origin !== window.location.origin || ev.source !== window.parent ) {
565 return;
566 }
567 var d = ev && ev.data;
568 if ( ! d || typeof d !== 'object' || d.type !== 'os-instrument-set' ) {
569 return;
570 }
571 window.__wpdInstrument = {
572 headers: d.headers && typeof d.headers === 'object' ? d.headers : {},
573 observe: !! d.observe
574 };
575 } );
576 } catch ( _err ) { /* swallow — instrumentation is best-effort */ }
577
578 var osReportNetwork = function ( method, url, status, duration, failed, extra ) {
579 try {
580 var msg = {
581 type: 'os-iframe-network',
582 method: String( method || 'GET' ).toUpperCase(),
583 url: String( url || '' ),
584 status: typeof status === 'number' ? status : 0,
585 duration: typeof duration === 'number' ? duration : 0,
586 failed: !! failed
587 };
588 if ( extra && window.__wpdInstrument && window.__wpdInstrument.observe ) {
589 if ( extra.requestHeaders ) {
590 msg.requestHeaders = extra.requestHeaders;
591 }
592 if ( extra.responseHeaders ) {
593 msg.responseHeaders = extra.responseHeaders;
594 }
595 }
596 window.parent.postMessage( msg, window.location.origin );
597 } catch ( _err ) { /* swallow */ }
598 };
599
600 // Helper — when an admin-side request returns 401/403 the
601 // session is most likely toast. Don't wait up to 60s for the
602 // next heartbeat tick to surface core's auth-check modal —
603 // force an immediate tick. `wp.heartbeat.connectNow()` is
604 // safe to call repeatedly; we still debounce to avoid storms
605 // when many requests fail at once. Same-origin gate keeps us
606 // out of third-party 403s. The URL gate avoids looping on
607 // heartbeat itself (heartbeat shouldn't 403 — but if it does
608 // the recursive connectNow would not help anyway).
609 var osAuthCheckCooldownUntil = 0;
610 var osMaybeForceAuthCheck = function ( status, url ) {
611 if ( status !== 401 && status !== 403 ) {
612 return;
613 }
614 var urlStr = String( url || '' );
615 if ( ! urlStr ) {
616 return;
617 }
618 // Cross-origin URLs aren't ours to interpret.
619 try {
620 var resolved = new URL( urlStr, window.location.href );
621 if ( resolved.origin !== window.location.origin ) {
622 return;
623 }
624 // Skip heartbeat to avoid recursion. Skip wp-login
625 // because the login iframe itself returns 4xx during
626 // the auth handshake and we don't want to retrigger.
627 if (
628 resolved.pathname.indexOf( '/wp-admin/admin-ajax.php' ) !== -1
629 && /(?:^|&|\?)action=heartbeat(?:&|$)/.test( resolved.search )
630 ) {
631 return;
632 }
633 if ( resolved.pathname.indexOf( '/wp-login.php' ) !== -1 ) {
634 return;
635 }
636 } catch ( _err ) {
637 return;
638 }
639 var now = Date.now();
640 if ( now < osAuthCheckCooldownUntil ) {
641 return;
642 }
643 osAuthCheckCooldownUntil = now + 5000;
644 try {
645 if (
646 window.wp
647 && window.wp.heartbeat
648 && typeof window.wp.heartbeat.connectNow === 'function'
649 ) {
650 window.wp.heartbeat.connectNow();
651 }
652 } catch ( _err ) { /* swallow */ }
653 };
654
655 // Helper — convert an arbitrary `init.headers` shape into a
656 // plain `{ name: value }` map so the instrument layer can
657 // merge contributed headers without caring whether the caller
658 // passed a Headers, an array of pairs, or a plain object.
659 var osHeadersToObject = function ( h ) {
660 var out = {};
661 if ( ! h ) {
662 return out;
663 }
664 if ( typeof Headers !== 'undefined' && h instanceof Headers ) {
665 try {
666 h.forEach( function ( v, k ) { out[ k ] = v; } );
667 } catch ( _e ) { /* swallow */ }
668 return out;
669 }
670 if ( Array.isArray( h ) ) {
671 for ( var i = 0; i < h.length; i++ ) {
672 if ( h[ i ] && h[ i ].length >= 2 ) {
673 out[ h[ i ][ 0 ] ] = h[ i ][ 1 ];
674 }
675 }
676 return out;
677 }
678 if ( typeof h === 'object' ) {
679 for ( var k in h ) {
680 if ( Object.prototype.hasOwnProperty.call( h, k ) ) {
681 out[ k ] = h[ k ];
682 }
683 }
684 }
685 return out;
686 };
687
688 // Helper — snapshot the contributed-header set at request time.
689 // Header values can theoretically come and go between requests
690 // (parent ref-counts contributions) so we read fresh on every
691 // call rather than caching at wrap time.
692 var osContributedHeaders = function () {
693 var inst = window.__wpdInstrument || {};
694 var headers = inst.headers || {};
695 var out = {};
696 for ( var k in headers ) {
697 if ( Object.prototype.hasOwnProperty.call( headers, k ) && typeof headers[ k ] === 'string' ) {
698 out[ k ] = headers[ k ];
699 }
700 }
701 return out;
702 };
703
704 // Wrap fetch. Called AFTER `admin_footer` runs — plugin code
705 // using fetch during synchronous page boot (rare in wp-admin)
706 // bypasses this, but lazy calls (the common case) are captured.
707 //
708 // Two layers of behavior:
709 //
710 // - Always: timing + status reporting (the original
711 // observability contract).
712 // - When `__wpdInstrument.headers` is non-empty: merge those
713 // headers into the request before dispatch so devtools can
714 // tag every outgoing call without each plugin reinventing
715 // a fetch wrapper.
716 // - When `__wpdInstrument.observe`: also relay request +
717 // response headers in the parent-bound network message.
718 if ( typeof window.fetch === 'function' ) {
719 var osOrigFetch = window.fetch;
720 window.fetch = function ( input, init ) {
721 var start = ( typeof performance !== 'undefined' && performance.now )
722 ? performance.now()
723 : Date.now();
724 var method = 'GET';
725 var url = '';
726 if ( typeof input === 'string' ) {
727 url = input;
728 if ( init && typeof init.method === 'string' ) {
729 method = init.method;
730 }
731 } else if ( input && typeof input === 'object' ) {
732 url = input.url || '';
733 method = ( input.method || ( init && init.method ) || 'GET' );
734 }
735
736 // Header contribution + capture. Build a single
737 // `Headers` instance so contributed values overwrite /
738 // stack predictably regardless of the caller's input
739 // shape, then re-attach to a cloned init.
740 var contributed = osContributedHeaders();
741 var observe = window.__wpdInstrument && window.__wpdInstrument.observe;
742 var requestHeaders = null;
743 var hasContributed = false;
744 for ( var ck in contributed ) {
745 if ( Object.prototype.hasOwnProperty.call( contributed, ck ) ) {
746 hasContributed = true;
747 break;
748 }
749 }
750 if ( hasContributed || observe ) {
751 var existing = osHeadersToObject( init && init.headers );
752 if ( input && typeof input === 'object' && input.headers ) {
753 var fromReq = osHeadersToObject( input.headers );
754 for ( var rk in fromReq ) {
755 if ( Object.prototype.hasOwnProperty.call( fromReq, rk ) && ! ( rk in existing ) ) {
756 existing[ rk ] = fromReq[ rk ];
757 }
758 }
759 }
760 for ( var ck2 in contributed ) {
761 if ( Object.prototype.hasOwnProperty.call( contributed, ck2 ) ) {
762 existing[ ck2 ] = contributed[ ck2 ];
763 }
764 }
765 if ( hasContributed ) {
766 init = init ? Object.assign( {}, init ) : {};
767 init.headers = existing;
768 arguments[ 1 ] = init;
769 }
770 if ( observe ) {
771 requestHeaders = existing;
772 }
773 }
774
775 var promise;
776 try {
777 promise = osOrigFetch.apply( this, arguments );
778 } catch ( sync ) {
779 osReportNetwork( method, url, 0, 0, true, requestHeaders ? { requestHeaders: requestHeaders } : null );
780 throw sync;
781 }
782 return promise.then(
783 function ( res ) {
784 var dur = ( ( typeof performance !== 'undefined' && performance.now )
785 ? performance.now()
786 : Date.now() ) - start;
787 var extra = null;
788 if ( requestHeaders ) {
789 extra = { requestHeaders: requestHeaders };
790 try {
791 var rh = {};
792 if ( res && res.headers && typeof res.headers.forEach === 'function' ) {
793 res.headers.forEach( function ( v, k ) { rh[ k ] = v; } );
794 }
795 extra.responseHeaders = rh;
796 } catch ( _hErr ) { /* swallow */ }
797 }
798 osReportNetwork( method, url, res.status, Math.round( dur ), ! res.ok, extra );
799 osMaybeForceAuthCheck( res.status, url );
800 return res;
801 },
802 function ( err ) {
803 var dur = ( ( typeof performance !== 'undefined' && performance.now )
804 ? performance.now()
805 : Date.now() ) - start;
806 osReportNetwork( method, url, 0, Math.round( dur ), true, requestHeaders ? { requestHeaders: requestHeaders } : null );
807 throw err;
808 }
809 );
810 };
811 }
812
813 // Wrap XHR — admin-ajax runs through jQuery which runs through
814 // XHR, so fetch-only instrumentation would miss most of the
815 // legacy admin surface. Record method + URL on open; fire on
816 // loadend regardless of success / failure.
817 //
818 // Header contribution layer: `setRequestHeader` after open() but
819 // before send() — that's the only window the spec allows. The
820 // caller's own headers are tracked so observation can include
821 // them alongside the contributed ones.
822 if ( typeof XMLHttpRequest !== 'undefined' ) {
823 var osOrigOpen = XMLHttpRequest.prototype.open;
824 var osOrigSend = XMLHttpRequest.prototype.send;
825 var osOrigSetHeader = XMLHttpRequest.prototype.setRequestHeader;
826 XMLHttpRequest.prototype.open = function ( method, url ) {
827 try {
828 this.__wpdMethod = method;
829 this.__wpdUrl = url;
830 this.__wpdReqHeaders = {};
831 } catch ( _err ) { /* frozen instance — skip */ }
832 return osOrigOpen.apply( this, arguments );
833 };
834 XMLHttpRequest.prototype.setRequestHeader = function ( name, value ) {
835 try {
836 if ( ! this.__wpdReqHeaders ) {
837 this.__wpdReqHeaders = {};
838 }
839 this.__wpdReqHeaders[ name ] = value;
840 } catch ( _err ) { /* swallow */ }
841 return osOrigSetHeader.apply( this, arguments );
842 };
843 XMLHttpRequest.prototype.send = function () {
844 var xhr = this;
845 var start = ( typeof performance !== 'undefined' && performance.now )
846 ? performance.now()
847 : Date.now();
848
849 // Apply contributed headers right before send. Doing it
850 // here rather than in open() means contributions added
851 // after open() (e.g. in async-built request flows) still
852 // land on the wire.
853 var contributed = osContributedHeaders();
854 var observe = window.__wpdInstrument && window.__wpdInstrument.observe;
855 for ( var hk in contributed ) {
856 if ( Object.prototype.hasOwnProperty.call( contributed, hk ) ) {
857 try {
858 osOrigSetHeader.call( xhr, hk, contributed[ hk ] );
859 if ( ! xhr.__wpdReqHeaders ) {
860 xhr.__wpdReqHeaders = {};
861 }
862 xhr.__wpdReqHeaders[ hk ] = contributed[ hk ];
863 } catch ( _hErr ) { /* `setRequestHeader` rejects forbidden names — skip */ }
864 }
865 }
866
867 var fire = function () {
868 var dur = ( ( typeof performance !== 'undefined' && performance.now )
869 ? performance.now()
870 : Date.now() ) - start;
871 var extra = null;
872 if ( observe ) {
873 extra = {
874 requestHeaders: xhr.__wpdReqHeaders || {}
875 };
876 try {
877 var raw = xhr.getAllResponseHeaders ? xhr.getAllResponseHeaders() : '';
878 var resHeaders = {};
879 if ( raw && typeof raw === 'string' ) {
880 var lines = raw.trim().split( /[\r\n]+/ );
881 for ( var li = 0; li < lines.length; li++ ) {
882 var idx = lines[ li ].indexOf( ':' );
883 if ( idx > 0 ) {
884 resHeaders[ lines[ li ].slice( 0, idx ).trim() ] = lines[ li ].slice( idx + 1 ).trim();
885 }
886 }
887 }
888 extra.responseHeaders = resHeaders;
889 } catch ( _rErr ) { /* swallow */ }
890 }
891 osReportNetwork(
892 xhr.__wpdMethod,
893 xhr.__wpdUrl,
894 xhr.status,
895 Math.round( dur ),
896 xhr.status === 0 || xhr.status >= 400,
897 extra
898 );
899 osMaybeForceAuthCheck( xhr.status, xhr.__wpdUrl );
900 };
901 try {
902 xhr.addEventListener( 'loadend', fire );
903 } catch ( _err ) { /* swallow */ }
904 return osOrigSend.apply( this, arguments );
905 };
906 }
907
908 // Wrap sendBeacon — used by analytics + telemetry. The Beacon
909 // API doesn't accept headers (the entire point of beacons is
910 // minimal payload + best-effort delivery). When devtools have
911 // contributed headers we silently fall back to fetch with
912 // `keepalive: true`, which is the closest semantic match —
913 // guaranteed POST + same fire-and-forget intent + custom headers
914 // allowed. Without contributions we just relay the call.
915 if ( typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function' ) {
916 var osOrigBeacon = navigator.sendBeacon.bind( navigator );
917 navigator.sendBeacon = function ( url, data ) {
918 var contributed = osContributedHeaders();
919 var hasContributed = false;
920 for ( var ck in contributed ) {
921 if ( Object.prototype.hasOwnProperty.call( contributed, ck ) ) {
922 hasContributed = true;
923 break;
924 }
925 }
926 var start = ( typeof performance !== 'undefined' && performance.now )
927 ? performance.now()
928 : Date.now();
929 if ( ! hasContributed ) {
930 var ok = false;
931 try { ok = !! osOrigBeacon( url, data ); } catch ( _e ) { ok = false; }
932 osReportNetwork( 'POST', url, ok ? 200 : 0, 0, ! ok );
933 return ok;
934 }
935 try {
936 var observe = window.__wpdInstrument && window.__wpdInstrument.observe;
937 var headers = {};
938 for ( var hk2 in contributed ) {
939 if ( Object.prototype.hasOwnProperty.call( contributed, hk2 ) ) {
940 headers[ hk2 ] = contributed[ hk2 ];
941 }
942 }
943 window.fetch( url, {
944 method: 'POST',
945 body: data,
946 keepalive: true,
947 credentials: 'same-origin',
948 headers: headers
949 } ).then(
950 function ( res ) {
951 var dur = ( ( typeof performance !== 'undefined' && performance.now )
952 ? performance.now()
953 : Date.now() ) - start;
954 osReportNetwork( 'POST', url, res.status, Math.round( dur ), ! res.ok, observe ? { requestHeaders: headers } : null );
955 },
956 function () {
957 var dur = ( ( typeof performance !== 'undefined' && performance.now )
958 ? performance.now()
959 : Date.now() ) - start;
960 osReportNetwork( 'POST', url, 0, Math.round( dur ), true, observe ? { requestHeaders: headers } : null );
961 }
962 );
963 return true;
964 } catch ( _bErr ) {
965 return false;
966 }
967 };
968 }
969 } catch ( _err ) {
970 /* Whole observability block is best-effort. If something in
971 * the environment disagrees (frozen prototypes, CSP blocking
972 * postMessage, etc.) we don't want to tank the rest of the
973 * chromeless bridge. */
974 }
975
976 /*
977 * Menu-changed signal.
978 *
979 * The shell's dock is built from `$menu` at page-load time and
980 * then frozen — the iframe reload that follows plugin
981 * activation / deactivation / installation doesn't tell the
982 * parent the admin menu just mutated. This handler fires inside
983 * the iframe that JUST LOADED plugins.php (or a sibling menu-
984 * affecting page) and hands the parent a fresh payload the PHP
985 * side built server-side from the live $menu globals.
986 *
987 * Why not a REST roundtrip: plugins commonly gate their
988 * `admin_menu` registration on `is_admin()` evaluated AT PLUGIN
989 * LOAD. REST requests don't define `WP_ADMIN` at plugin-load
990 * time, so those plugins never register and a REST-context
991 * bootstrap can't retroactively make them. By capturing the
992 * payload here, inside a real admin context, we get the
993 * authoritative post-activation state that any REST endpoint
994 * would miss.
995 *
996 * Covered pages:
997 * - plugins.php — activate, deactivate, bulk, delete.
998 * - plugin-install.php — install new, install-and-activate.
999 * - update.php — update / install handler (install-
1000 * plugin + upload-plugin actions).
1001 * - themes.php — theme switch (rare but can add menus).
1002 */
1003 var __OPENSTATION_MENU_PAYLOAD__ = /*__OPENSTATION_MENU_PAYLOAD__*/;
1004 var __OPENSTATION_MENU_SIG__ = /*__OPENSTATION_MENU_SIG__*/;
1005 /*
1006 * Icon harvest from the iframe's authoritative #adminmenu.
1007 *
1008 * The server-side payload only knows what the plugin set on
1009 * $menu[$i][6]. Plugins that register their icon with 'none' /
1010 * 'div' and paint it via a CSS rule on `#adminmenu .menu-icon-X`
1011 * (All in One WP Migration, plus a long tail of older plugins)
1012 * end up serialized with the gear fallback.
1013 *
1014 * On a regular page load the parent shell's resolveIcon() falls
1015 * back to the parent's hidden #adminmenu DOM and reads the icon
1016 * from there — but on a live activation the parent's #adminmenu
1017 * is stale (it was rendered before the plugin existed). This
1018 * iframe just rendered plugins.php in real admin context, so its
1019 * own #adminmenu DOM IS authoritative; harvest each menu item's
1020 * resolved icon here and patch the dockItems before postMessage.
1021 */
1022 try {
1023 if (
1024 __OPENSTATION_MENU_PAYLOAD__
1025 && Array.isArray( __OPENSTATION_MENU_PAYLOAD__.dockItems )
1026 ) {
1027 var __wpdAdminMenu = document.getElementById( 'adminmenu' );
1028 if ( __wpdAdminMenu ) {
1029 var __wpdHarvest = {};
1030 var __wpdLinks = __wpdAdminMenu.querySelectorAll( 'li.menu-top > a' );
1031 for ( var __wpdLi = 0; __wpdLi < __wpdLinks.length; __wpdLi++ ) {
1032 var __wpdLink = __wpdLinks[ __wpdLi ];
1033 var __wpdKey;
1034 try {
1035 var __wpdU = new URL( __wpdLink.href || '', window.location.href );
1036 __wpdKey = ( __wpdU.pathname.split( '/' ).pop() || '' ) + __wpdU.search;
1037 } catch ( __wpdE1 ) { continue; }
1038 if ( ! __wpdKey ) { continue; }
1039 var __wpdImgWrap = __wpdLink.querySelector( '.wp-menu-image' );
1040 if ( ! __wpdImgWrap ) { continue; }
1041
1042 /* (a) <img src> nested inside .wp-menu-image */
1043 var __wpdImg = __wpdImgWrap.querySelector( 'img' );
1044 if ( __wpdImg && __wpdImg.src ) {
1045 __wpdHarvest[ __wpdKey ] = __wpdImg.src;
1046 continue;
1047 }
1048
1049 /* (b) dashicon class on the wrap div itself */
1050 var __wpdDash = ( __wpdImgWrap.className || '' ).match( /\bdashicons-[\w-]+\b/ );
1051 if (
1052 __wpdDash
1053 && __wpdDash[ 0 ] !== 'dashicons-before'
1054 && __wpdDash[ 0 ] !== 'dashicons-admin-generic'
1055 ) {
1056 __wpdHarvest[ __wpdKey ] = __wpdDash[ 0 ];
1057 continue;
1058 }
1059
1060 /* (c) ::before background-image — pass the raw
1061 * `url(...)` CSS value through; the parent's
1062 * resolveIcon can hand it straight to _makeSvgIcon
1063 * regardless of whether it's base64-encoded SVG,
1064 * URL-encoded SVG, or a plain http(s) URL. */
1065 try {
1066 var __wpdBefore = window.getComputedStyle( __wpdImgWrap, '::before' );
1067 var __wpdBg = __wpdBefore && __wpdBefore.backgroundImage;
1068 if ( __wpdBg && __wpdBg !== 'none' && __wpdBg.indexOf( 'url("")' ) === -1 ) {
1069 __wpdHarvest[ __wpdKey ] = __wpdBg;
1070 continue;
1071 }
1072 /* (d) background on the wrap itself */
1073 var __wpdWrapBg = window.getComputedStyle( __wpdImgWrap ).backgroundImage;
1074 if ( __wpdWrapBg && __wpdWrapBg !== 'none' && __wpdWrapBg.indexOf( 'url("")' ) === -1 ) {
1075 __wpdHarvest[ __wpdKey ] = __wpdWrapBg;
1076 }
1077 } catch ( __wpdE2 ) { /* getComputedStyle may throw on detached nodes */ }
1078 }
1079
1080 var __wpdItems = __OPENSTATION_MENU_PAYLOAD__.dockItems;
1081 for ( var __wpdDi = 0; __wpdDi < __wpdItems.length; __wpdDi++ ) {
1082 var __wpdItem = __wpdItems[ __wpdDi ];
1083 if ( ! __wpdItem || __wpdItem.icon !== 'dashicons-admin-generic' ) { continue; }
1084 if ( typeof __wpdItem.url !== 'string' || ! __wpdItem.url ) { continue; }
1085 try {
1086 var __wpdItemU = new URL( __wpdItem.url, window.location.href );
1087 var __wpdItemKey = ( __wpdItemU.pathname.split( '/' ).pop() || '' ) + __wpdItemU.search;
1088 if ( __wpdHarvest[ __wpdItemKey ] ) {
1089 __wpdItem.icon = __wpdHarvest[ __wpdItemKey ];
1090 }
1091 } catch ( __wpdE3 ) { /* malformed url — leave icon alone */ }
1092 }
1093 }
1094 }
1095 } catch ( __wpdHarvestErr ) {
1096 /* Harvest is best-effort; on any failure we still ship the
1097 * server-built payload, which is exactly the pre-fix behavior. */
1098 }
1099 /*
1100 * Menu payload / signature target: the SHELL, i.e. the top window —
1101 * not the immediate parent. For a normal window iframe the two are
1102 * the same frame, but the bulk updater nests: update-core.php (the
1103 * window iframe) hosts a progress iframe of `update.php?action=
1104 * update-selected`, whose `iframe_footer()` fires `admin_footer`
1105 * AFTER the upgrades ran — exactly the fresh payload the shell
1106 * wants. Posting that to `window.parent` hands it to the
1107 * update-core.php page, which has no listener, and the dock badge
1108 * stays stale (GH#296). `window.top` reaches the shell from any
1109 * nesting depth; the targetOrigin pin means a cross-origin top
1110 * (foreign page iframing wp-admin) simply never receives it.
1111 */
1112 try {
1113 var __wpdShell = window.top || window.parent;
1114 if ( __OPENSTATION_MENU_PAYLOAD__ ) {
1115 __wpdShell.postMessage(
1116 {
1117 type: 'os-plugins-changed',
1118 payload: __OPENSTATION_MENU_PAYLOAD__
1119 },
1120 window.location.origin
1121 );
1122 } else if ( __OPENSTATION_MENU_SIG__ ) {
1123 /*
1124 * No full payload on this page — but we still ship the cheap
1125 * menu signature so the shell can notice a menu change that
1126 * happened somewhere off the plugins/themes/update path (a
1127 * CPT registered via a settings tool, a plugin that adds a
1128 * menu on save, …) and spend a refresh probe only then.
1129 * GH#325.
1130 */
1131 __wpdShell.postMessage(
1132 {
1133 type: 'os-menu-signature',
1134 sig: __OPENSTATION_MENU_SIG__
1135 },
1136 window.location.origin
1137 );
1138 }
1139 } catch ( err ) {
1140 /* postMessage throws only on structured-clone failures, which
1141 * this static payload won't hit. Swallow defensively so a
1142 * wayward extension wrapping window.parent can't break the
1143 * rest of the bridge. */
1144 }
1145
1146 /*
1147 * Link & form interceptor.
1148 *
1149 * Every same-origin wp-admin <a> href and <form> action gets the
1150 * `openstation_chromeless=1` flag appended so navigation inside the iframe stays
1151 * chromeless. Without this, a stray link to /wp-admin/edit.php (see
1152 * Gutenberg's fullscreen close button, help-tab links, "Return to
1153 * posts" affordances, etc.) re-renders the full classic admin inside
1154 * our window.
1155 *
1156 * Excluded from rewriting:
1157 * - modifier clicks (cmd/ctrl/shift/alt) — user wants to open a
1158 * new tab/window, respect that
1159 * - target="_blank" / target="_top" / target="_parent"
1160 * - download attribute
1161 * - in-page anchors (#)
1162 * - mailto:, tel:, javascript: schemes
1163 * - cross-origin URLs
1164 * - URLs that already carry openstation_chromeless=
1165 */
1166 function rewriteAdminUrl( href, base ) {
1167 if ( ! href || href.charAt( 0 ) === '#' ) {
1168 return null;
1169 }
1170 if ( /^(mailto:|tel:|javascript:|data:)/i.test( href ) ) {
1171 return null;
1172 }
1173 var url;
1174 try {
1175 url = new URL( href, base );
1176 } catch ( err ) {
1177 return null;
1178 }
1179 if ( url.origin !== window.location.origin ) {
1180 return null;
1181 }
1182 if ( url.pathname.indexOf( '/wp-admin/' ) === -1 ) {
1183 return null;
1184 }
1185 if ( url.searchParams.has( 'openstation_chromeless' ) ) {
1186 return null;
1187 }
1188 url.searchParams.set( 'openstation_chromeless', '1' );
1189 return url.toString();
1190 }
1191
1192 /*
1193 * Classify a link so we know whether to rewrite it (admin),
1194 * escalate it to the parent shell (external / non-admin), or let
1195 * the browser navigate naturally (mailto, anchor, download, etc.).
1196 *
1197 * 'admin' — same-origin /wp-admin/ URL we rewrite in place.
1198 * 'external' — http(s) URL we want the parent shell to open
1199 * as a sub-tab instead of navigating the iframe
1200 * out of wp-admin. Covers both cross-origin
1201 * links (plugin author sites, external docs) AND
1202 * same-origin non-admin links (the site's own
1203 * front-end pages).
1204 * 'passthrough' — anything else (mailto, tel, javascript, data,
1205 * anchors, unparseable). The browser handles it.
1206 */
1207 function classifyLink( href, base ) {
1208 if ( ! href || href.charAt( 0 ) === '#' ) {
1209 return 'passthrough';
1210 }
1211 if ( /^(mailto:|tel:|javascript:|data:)/i.test( href ) ) {
1212 return 'passthrough';
1213 }
1214 var url;
1215 try {
1216 url = new URL( href, base );
1217 } catch ( err ) {
1218 return 'passthrough';
1219 }
1220 if ( url.protocol !== 'http:' && url.protocol !== 'https:' ) {
1221 return 'passthrough';
1222 }
1223 if (
1224 url.origin === window.location.origin &&
1225 url.pathname.indexOf( '/wp-admin/' ) !== -1
1226 ) {
1227 return 'admin';
1228 }
1229 return 'external';
1230 }
1231
1232 /*
1233 * Rewrite a link's href to carry `_wp_http_referer=<this page>`.
1234 *
1235 * The iframe-side twin of `stampSourceReferer()` in
1236 * `src/window/iframe-bridge.ts`, for links the interceptor yields
1237 * on: the parent never sees the click, so it can't stamp them.
1238 * `wp_get_referer()` reads `$_REQUEST['_wp_http_referer']` ahead
1239 * of the raw header, so the param survives any `Referrer-Policy`.
1240 *
1241 * Same three guards as the parent, for the same reasons: never
1242 * overwrite a referer the markup already supplied, stay
1243 * same-origin (a mis-attributed referer is worse than none), and
1244 * strip the chromeless flag from the hint so it doesn't loop into
1245 * whatever redirect WP builds out of it.
1246 */
1247 function stampSourceRefererOnLink( link ) {
1248 try {
1249 var target = new URL( link.getAttribute( 'href' ), window.location.href );
1250 if ( target.origin !== window.location.origin ) {
1251 return;
1252 }
1253 if ( target.searchParams.has( '_wp_http_referer' ) ) {
1254 return;
1255 }
1256 var source = new URL( window.location.href );
1257 source.searchParams.delete( 'openstation_chromeless' );
1258 target.searchParams.set(
1259 '_wp_http_referer',
1260 source.pathname + ( source.search ? source.search : '' )
1261 );
1262 link.setAttribute( 'href', target.toString() );
1263 } catch ( err ) {
1264 /* Unparseable href, so leave the link exactly as it was. */
1265 }
1266 }
1267
1268 /*
1269 * The text a link actually SHOWS, for use as a window title.
1270 *
1271 * `textContent` is the wrong source on its own. WP Core routinely
1272 * pairs a terse visible label with a longer screen-reader one
1273 * inside the same anchor, and reads back as both at once. Drop the
1274 * screen-reader half (`.screen-reader-text` is Core's own class for
1275 * it, `.hidden` covers the markup that toggles) and collapse the
1276 * indentation whitespace the templates leave behind.
1277 *
1278 * The parent treats a label harvested this way as provisional and
1279 * upgrades it to the destination page's own title once the iframe
1280 * loads — see `titleFromPage` in `src/types.ts`.
1281 */
1282 /*
1283 * The wp-admin filename a URL points at (`revision.php`), or an
1284 * empty string when it can't be read. Used to tell "a different
1285 * admin screen" from "another view of this one" without needing
1286 * the shell's window-slug rules.
1287 */
1288 function adminFileOf( url ) {
1289 try {
1290 return new URL( url, window.location.href ).pathname.split( '/' ).pop();
1291 } catch ( err ) {
1292 return '';
1293 }
1294 }
1295
1296 function visibleLinkText( link ) {
1297 var clone = link.cloneNode( true );
1298 var muted = clone.querySelectorAll( '.screen-reader-text, .hidden' );
1299 for ( var i = 0; i < muted.length; i++ ) {
1300 if ( muted[ i ].parentNode ) {
1301 muted[ i ].parentNode.removeChild( muted[ i ] );
1302 }
1303 }
1304 return ( clone.textContent || '' ).replace( /\s+/g, ' ' ).trim();
1305 }
1306
1307 document.addEventListener( 'click', function ( e ) {
1308 if ( e.defaultPrevented ) {
1309 return;
1310 }
1311 if ( e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey ) {
1312 return;
1313 }
1314 var link = e.target && e.target.closest ? e.target.closest( 'a[href]' ) : null;
1315 if ( ! link ) {
1316 return;
1317 }
1318 /*
1319 * A link that names another browsing context.
1320 *
1321 * `_blank` on a /wp-admin/ URL means "open this admin screen
1322 * without losing the one I am on", and inside the shell that
1323 * is another window rather than a browser tab that drops the
1324 * user out of the desktop. Claimed only when the destination
1325 * is a DIFFERENT wp-admin file: whether two URLs on one file
1326 * are the same "page" depends on the shell's window-slug
1327 * rules, which the iframe can't see, and guessing wrong makes
1328 * the parent navigate the window the link was clicked in.
1329 *
1330 * Every other target yields. `_top` / `_parent` are a
1331 * deliberate "replace the whole shell" and a page's only
1332 * escape hatch; a named target (`wp-preview-4`) reuses one
1333 * specific tab across clicks, which a window cannot honour;
1334 * and any target on a non-admin URL has no window to open
1335 * into.
1336 */
1337 var newContext = false;
1338 var linkTarget = link.target || '';
1339 if ( linkTarget !== '' && linkTarget !== '_self' ) {
1340 var claimable =
1341 linkTarget === '_blank' &&
1342 classifyLink( link.getAttribute( 'href' ), window.location.href ) === 'admin' &&
1343 adminFileOf( link.getAttribute( 'href' ) ) !== '' &&
1344 adminFileOf( link.getAttribute( 'href' ) ) !== adminFileOf( window.location.href );
1345 if ( ! claimable ) {
1346 return;
1347 }
1348 newContext = true;
1349 }
1350 if ( link.hasAttribute( 'download' ) ) {
1351 return;
1352 }
1353 /*
1354 * Activity-footprint launcher. A "View activity footprint" row
1355 * action (added to the Users list table by
1356 * `openstation_user_footprint_row_action`) carries the target
1357 * user id in `data-os-footprint`. The iframe has no
1358 * shell API of its own, so we escalate the click to the parent
1359 * shell, which opens the My WordPress window on that user's
1360 * footprint. Checked BEFORE classifyLink so the link's real
1361 * href — a graceful profile-edit fallback for no-JS — is never
1362 * followed inside the shell. Modifier-key / middle clicks are
1363 * already filtered above, so cmd/ctrl-click still opens that
1364 * fallback in a new browser tab.
1365 */
1366 var footprintAttr = link.getAttribute( 'data-os-footprint' );
1367 if ( footprintAttr ) {
1368 var footprintUid = parseInt( footprintAttr, 10 );
1369 if ( footprintUid > 0 ) {
1370 e.preventDefault();
1371 try {
1372 window.parent.postMessage(
1373 {
1374 type: 'os-open-user-footprint',
1375 userId: footprintUid,
1376 userName: link.getAttribute( 'data-os-footprint-name' ) || ''
1377 },
1378 window.location.origin
1379 );
1380 } catch ( footprintErr ) {
1381 /* Same-origin postMessage can only fail in a sandbox
1382 * we don't support — swallow rather than block the
1383 * click. */
1384 }
1385 return;
1386 }
1387 }
1388 /*
1389 * `aria-button-if-js` is WP core's own marker for "this anchor
1390 * is really an in-page button; the href is only the no-JS
1391 * fallback". Core stamps `role="button"` on every one of them
1392 * (`wp-admin/js/common.js`), and the owning script binds a
1393 * bubble-phase handler that calls preventDefault: media-grid.js
1394 * for the Media Library's uploader toggle, wp-lists for the
1395 * comment row actions, tags.js for term Delete, updates.js
1396 * for the auto-update toggles.
1397 *
1398 * Our capture-phase handler runs first, so hijacking these
1399 * substitutes the fallback URL for the in-page action the user
1400 * actually asked for. On the Media Library grid that showed up as
1401 * two uploaders at once: the shell opened a window for
1402 * `media-new.php` (the fallback) while media-grid.js's
1403 * `addNewClickHandler` still expanded the inline drop zone in the
1404 * Media window behind it, and closing the window left the drop
1405 * zone stranded above the grid.
1406 *
1407 * The class does NOT promise a handler, though. The Media list
1408 * table stamps it on Trash / Restore / Delete Permanently
1409 * (`.submitdelete`, `class-wp-media-list-table.php`) and binds
1410 * nothing: the href really is the navigation. Yielding is still
1411 * right for those (the inline `onclick` confirm runs, and
1412 * cancelling actually cancels, which it did not when we
1413 * preventDefaulted in capture ahead of it), but the parent's
1414 * destructive-action path used to stamp `_wp_http_referer` on
1415 * them, and a raw navigation loses that. See `stampSourceReferer`
1416 * in `src/window/iframe-bridge.ts` for the full rationale; the
1417 * short version is that a `Referrer-Policy` of `strict-origin` or
1418 * tighter downgrades the `Referer` header to the bare origin,
1419 * which `post.php` matches against neither `post.php` nor
1420 * `post-new.php`, so `$sendback` stays the origin and the window
1421 * lands on the site front page instead of back on the list.
1422 *
1423 * So stamp the hint ourselves before yielding. In here the
1424 * source page IS `window.location`, which makes this the same
1425 * value the parent would have computed, minus the round trip.
1426 */
1427 if ( link.classList.contains( 'aria-button-if-js' ) ) {
1428 if ( link.classList.contains( 'submitdelete' ) ) {
1429 stampSourceRefererOnLink( link );
1430 }
1431 return;
1432 }
1433 /*
1434 * Same story as `aria-button-if-js` above, minus the marker
1435 * class: `plugin-install.php`'s "Upload Plugin" href is a
1436 * no-JS fallback, and plugin-install.js binds a bubble-phase
1437 * handler that opens the drop zone in place above the plugin
1438 * cards. Our capture handler used to win and navigate to
1439 * `?tab=upload`, which shows the uploader with no cards.
1440 *
1441 * On that page core skips the binding on purpose ("let the
1442 * link behave like a link"), flagged by
1443 * `plugin-install-tab-upload` on the wrap. There the href is
1444 * the real navigation, so we route it as usual.
1445 *
1446 * `theme-install.php`'s Upload Theme is a `<button>`, so it
1447 * never reaches this handler.
1448 */
1449 if ( link.classList.contains( 'upload-view-toggle' ) ) {
1450 var uploadWrap = link.closest( '.wrap' );
1451 if (
1452 ! uploadWrap ||
1453 ! uploadWrap.classList.contains( 'plugin-install-tab-upload' )
1454 ) {
1455 return;
1456 }
1457 }
1458 /*
1459 * WordPress core's wp-admin/js/updates.js owns the click on these
1460 * AJAX-driven plugin/theme management buttons — it binds in bubble
1461 * phase and calls preventDefault to take over with an in-place
1462 * AJAX install / update / delete (with its own progress spinner
1463 * and inline success/failure UX). Our capture-phase handler would
1464 * preempt it: preventDefault here fires BEFORE updates.js's own,
1465 * the AJAX call never starts, and the postMessage below diverts
1466 * the user to the link's no-JS fallback URL (update.php?action=
1467 * install-plugin&...) opened as a freshly spawned desktop window.
1468 * That fallback technically completes the install server-side,
1469 * but it's a long blocking page-load with no in-place feedback —
1470 * which is what users perceive as "Install Now keeps loading and
1471 * opens a new tab". Skip these classes so updates.js's bubble
1472 * handler runs as core intended.
1473 *
1474 * The plugins-list-table row action "Delete" is the same story
1475 * with a different marker: a bare `a.delete` inside a
1476 * `tr[data-plugin]` (updates.js binds `[data-plugin] a.delete`;
1477 * the network themes list is `.themes-php.network-admin
1478 * a.delete`) — it never carries the `delete-plugin` /
1479 * `delete-theme` classes of the card-style buttons above.
1480 * Hijacking it navigated the iframe to the link's no-JS
1481 * bulk-delete fallback WHILE updates.js's AJAX delete was
1482 * already running: `wp.updates.beforeunload` raised a native
1483 * "Leave site?" prompt, and leaving landed on a delete
1484 * confirmation screen for a plugin whose files the AJAX call
1485 * had just removed — an empty "You are about to remove:" list.
1486 */
1487 if (
1488 link.classList.contains( 'install-now' ) ||
1489 link.classList.contains( 'update-link' ) ||
1490 link.classList.contains( 'update-now' ) ||
1491 link.classList.contains( 'delete-plugin' ) ||
1492 link.classList.contains( 'delete-theme' ) ||
1493 link.classList.contains( 'install-theme' ) ||
1494 ( link.classList.contains( 'delete' ) &&
1495 ( link.closest( '[data-plugin]' ) ||
1496 ( document.body.classList.contains( 'themes-php' ) &&
1497 document.body.classList.contains( 'network-admin' ) ) ) )
1498 ) {
1499 return;
1500 }
1501 var href = link.getAttribute( 'href' );
1502 var kind = classifyLink( href, window.location.href );
1503 if ( kind === 'admin' ) {
1504 var rewritten = rewriteAdminUrl( href, window.location.href );
1505 if ( rewritten ) {
1506 link.setAttribute( 'href', rewritten );
1507 }
1508 /*
1509 * Hand admin-internal navigation to the parent shell.
1510 *
1511 * The parent decides what to do with each click:
1512 *
1513 * - Native-window remap hits (e.g. `edit.php` while the
1514 * user has the native Posts opt-in on) → parent opens
1515 * the native window and closes THIS iframe.
1516 * - Same-page nav (pagination, filtering on the same
1517 * `edit.php?post_type=page` screen, etc.) → parent
1518 * drives the iframe's `location.assign()` so the
1519 * in-place navigation matches the user's intent.
1520 * - Cross-page nav (e.g. clicking "Posts" from inside
1521 * the Pages window) → parent opens a new window for
1522 * the destination and leaves THIS iframe untouched,
1523 * so the user keeps both contexts.
1524 *
1525 * We `preventDefault()` so the iframe never starts a
1526 * navigation the parent might want to suppress; otherwise
1527 * cross-page clicks would trash the source window before
1528 * the parent had a chance to react. Modifier-key clicks
1529 * (cmd/ctrl/shift/alt, middle-click) are already filtered
1530 * upstream so the browser's native "open in new tab" path
1531 * still works.
1532 */
1533 e.preventDefault();
1534 try {
1535 var absolute = new URL( rewritten || href, window.location.href ).toString();
1536 /*
1537 * Ship the link's visible text along with the URL so
1538 * the parent can title a freshly-opened window with
1539 * something the user recognises ("Scheduler") instead
1540 * of the URL slug ("tools-php-page-scheduler") when
1541 * the destination has no dock tile to copy a title
1542 * from. The iframe itself never auto-emits a
1543 * title-change, so without this hint the slug-as-
1544 * title fallback would persist for the lifetime of
1545 * the new window.
1546 */
1547 var adminLabel = visibleLinkText( link ) ||
1548 link.getAttribute( 'title' ) ||
1549 link.getAttribute( 'aria-label' ) ||
1550 '';
1551 window.parent.postMessage(
1552 {
1553 type: 'os-iframe-admin-link',
1554 url: absolute,
1555 label: adminLabel.slice( 0, 80 ),
1556 /*
1557 * The link asked for a new browsing context, so
1558 * the parent must give the destination its own
1559 * window rather than driving this one. Without
1560 * the flag it would still be free to pick an
1561 * in-place branch — the destructive-action one
1562 * fires on slug mismatch, which is exactly the
1563 * shape a `_blank` reaches us with.
1564 */
1565 newContext: newContext
1566 },
1567 window.location.origin
1568 );
1569 } catch ( bridgeErr ) {
1570 /* Same-origin postMessage to the same window can only fail in
1571 * a sandbox we don't support — swallow rather than block the
1572 * click. */
1573 }
1574 return;
1575 }
1576 if ( kind === 'external' ) {
1577 /*
1578 * External navigation inside an admin iframe would leave
1579 * the user stranded in a chrome-free version of whatever
1580 * site the link points at. Escalate to the parent shell
1581 * so it opens the URL as a closeable sub-tab (with a
1582 * detach button) alongside the admin tab — the user
1583 * stays inside the desktop shell.
1584 *
1585 * Resolving the href against the document base gives the
1586 * parent an absolute URL it doesn't have to re-resolve.
1587 */
1588 e.preventDefault();
1589 var absolute;
1590 try {
1591 absolute = new URL( href, window.location.href ).toString();
1592 } catch ( err ) {
1593 return;
1594 }
1595 var label = visibleLinkText( link ) ||
1596 link.getAttribute( 'title' ) ||
1597 absolute;
1598 window.parent.postMessage(
1599 {
1600 type: 'os-external-link',
1601 url: absolute,
1602 label: label.slice( 0, 80 )
1603 },
1604 window.location.origin
1605 );
1606 }
1607 }, true );
1608
1609 document.addEventListener( 'submit', function ( e ) {
1610 var form = e.target;
1611 if ( ! form || form.tagName !== 'FORM' ) {
1612 return;
1613 }
1614 var action = form.getAttribute( 'action' );
1615 var rewritten = rewriteAdminUrl( action || window.location.href, window.location.href );
1616 if ( rewritten ) {
1617 form.setAttribute( 'action', rewritten );
1618 }
1619 }, true );
1620
1621 /*
1622 * Focus-request bridge.
1623 *
1624 * Clicks inside an iframe don't cross the browsing-context
1625 * boundary — the parent shell's pointerdown / focusin listeners
1626 * never see them, so without this hook the only way to focus an
1627 * iframe window would be clicking its title bar chrome. Post a
1628 * `os-focus-request` message on every pointerdown; the
1629 * parent Window class treats it as an onFocusRequest. Capture
1630 * phase so the signal fires before any stopPropagation inside
1631 * a page's own handlers.
1632 */
1633 function postFocusRequest() {
1634 try {
1635 window.parent.postMessage(
1636 { type: 'os-focus-request' },
1637 window.location.origin
1638 );
1639 } catch ( err ) {
1640 /* cross-origin parent (shouldn't happen for chromeless
1641 * pages, but don't let a throw break the bridge) */
1642 }
1643 }
1644
1645 document.addEventListener( 'pointerdown', postFocusRequest, true );
1646
1647 /*
1648 * Nested-frame focus escalation.
1649 *
1650 * The document-level listener above never hears clicks inside
1651 * NESTED iframes: Gutenberg renders the post canvas in one
1652 * (`editor-canvas`, srcdoc → same-origin), and TinyMCE's visual
1653 * mode uses `#content_ifr`. Without this hook, clicking into the
1654 * canvas of an unfocused editor window is swallowed — only the
1655 * toolbar/sidebar (outer document) would focus the window.
1656 * Attach the same escalation inside every same-origin nested
1657 * frame: on load (each navigation creates a fresh document) and
1658 * as frames mount (Gutenberg creates the canvas asynchronously
1659 * and re-creates it, e.g. on device-preview switches).
1660 *
1661 * The observer walks only each record's `addedNodes` — the same
1662 * shape as the component-sniffer observer at the top of this
1663 * file, and for the same reason. Re-querying the whole document
1664 * per mutation batch would put an O(DOM) tree walk on Gutenberg's
1665 * typing path, which is precisely when the editor mutates hardest
1666 * (and precisely when the editor-preview pairing is live). A
1667 * frame that was never inserted cannot need hooking, so the
1668 * narrow sweep loses nothing. The WeakSets keep it idempotent
1669 * when a subtree is moved rather than created.
1670 */
1671 var hookedFrameDocs = new WeakSet();
1672 var hookedFrameEls = new WeakSet();
1673
1674 function hookNestedFrameDoc( frame ) {
1675 var doc;
1676 try {
1677 doc = frame.contentDocument;
1678 } catch ( err ) {
1679 return; /* cross-origin frame — unreachable, skip */
1680 }
1681 if ( ! doc || hookedFrameDocs.has( doc ) ) {
1682 return;
1683 }
1684 hookedFrameDocs.add( doc );
1685 doc.addEventListener( 'pointerdown', postFocusRequest, true );
1686 }
1687
1688 function hookNestedFrame( frame ) {
1689 if ( ! hookedFrameEls.has( frame ) ) {
1690 hookedFrameEls.add( frame );
1691 frame.addEventListener( 'load', function ( ev ) {
1692 hookNestedFrameDoc( ev.target );
1693 } );
1694 }
1695 hookNestedFrameDoc( frame );
1696 }
1697
1698 /*
1699 * Hook every iframe at or below `root`. `root` is the document on
1700 * the initial sweep and a freshly-added node thereafter, so the
1701 * walk stays proportional to what actually changed.
1702 */
1703 function hookNestedFrames( root ) {
1704 if ( ! root || ( 1 !== root.nodeType && 9 !== root.nodeType ) ) {
1705 return; /* text / comment node — nothing to walk */
1706 }
1707 if ( 'IFRAME' === root.nodeName ) {
1708 hookNestedFrame( root );
1709 }
1710 var frames = root.querySelectorAll( 'iframe' );
1711 for ( var i = 0; i < frames.length; i++ ) {
1712 hookNestedFrame( frames[ i ] );
1713 }
1714 }
1715
1716 hookNestedFrames( document );
1717 if ( window.MutationObserver ) {
1718 new MutationObserver( function ( records ) {
1719 for ( var r = 0; r < records.length; r++ ) {
1720 var added = records[ r ].addedNodes;
1721 for ( var n = 0; n < added.length; n++ ) {
1722 hookNestedFrames( added[ n ] );
1723 }
1724 }
1725 } ).observe(
1726 document.documentElement,
1727 { childList: true, subtree: true }
1728 );
1729 }
1730
1731 /*
1732 * OS-file drop forwarder. When the user drags a file from the
1733 * host OS into a chromeless admin iframe, intercept the drop
1734 * before the browser's default "navigate the iframe to the
1735 * file" handler fires, and `postMessage` the raw `File[]` up
1736 * to the parent shell so the OS-file drop manager
1737 * (`src/os-file-drop/manager.ts`) can show the upload dialog.
1738 *
1739 * Same-origin postMessage preserves `File` identity — the
1740 * parent receives real `File` objects, no base64 round-trip.
1741 *
1742 * We only intercept drops whose `DataTransfer.types` includes
1743 * `'Files'`. In-page DnD (Gutenberg block reorders, media
1744 * library drags) carries non-`Files` types and passes through
1745 * untouched.
1746 */
1747 function bridgeHasFiles( ev ) {
1748 var t = ev && ev.dataTransfer && ev.dataTransfer.types;
1749 if ( ! t ) {
1750 return false;
1751 }
1752 if ( typeof t.includes === 'function' ) {
1753 return t.includes( 'Files' );
1754 }
1755 if ( typeof t.contains === 'function' ) {
1756 return t.contains( 'Files' );
1757 }
1758 for ( var i = 0; i < t.length; i++ ) {
1759 if ( t[ i ] === 'Files' ) {
1760 return true;
1761 }
1762 }
1763 return false;
1764 }
1765 /*
1766 * Selectors of in-iframe drop receivers we leave alone —
1767 * Gutenberg's drop zone, the legacy media uploader, any
1768 * element a plugin marks with `data-drop-zone`. The whole
1769 * point: file drops onto Gutenberg blocks keep firing
1770 * Gutenberg's handler; only drops on the empty page
1771 * background escalate to the shell.
1772 */
1773 var bridgeDropPassthroughSelectors = [
1774 '.components-drop-zone',
1775 '[data-drop-zone]',
1776 '.uploader-window',
1777 '.media-frame-content'
1778 ];
1779 function bridgeDropTargetWantsFile( target ) {
1780 if ( ! target || ! target.closest ) {
1781 return false;
1782 }
1783 for ( var s = 0; s < bridgeDropPassthroughSelectors.length; s++ ) {
1784 if ( target.closest( bridgeDropPassthroughSelectors[ s ] ) ) {
1785 return true;
1786 }
1787 }
1788 return false;
1789 }
1790 /*
1791 * Bubble phase (not capture): the inner-most handler — Gutenberg's
1792 * drop zone, the legacy media uploader, or a third-party plugin
1793 * like "Administrador de archivos WP" — runs FIRST and gets the
1794 * chance to call `preventDefault()` to claim the drop. Our
1795 * forwarder then runs LAST at the document level and yields to
1796 * anyone who already took ownership.
1797 *
1798 * Two bail conditions, in order:
1799 * 1. `bridgeDropTargetWantsFile()` — the curated allowlist
1800 * (Gutenberg, wp.media, anything tagged `[data-drop-zone]`).
1801 * Kept as the primary check so the well-known core surfaces
1802 * behave identically to before, even if some edge case skips
1803 * the `preventDefault()` step.
1804 * 2. `ev.defaultPrevented` — the universal HTML5 contract: any
1805 * drop zone willing to receive a file calls `preventDefault()`
1806 * on `dragover` (mandatory per spec) and `drop` (to suppress
1807 * the browser's default navigate-to-file). When that's true,
1808 * some inner handler has taken the drop — yield so plugins
1809 * outside the allowlist (WP File Manager, Yoast, etc.) keep
1810 * their native UX.
1811 */
1812 document.addEventListener( 'dragover', function ( ev ) {
1813 if ( ! bridgeHasFiles( ev ) ) {
1814 return;
1815 }
1816 if ( bridgeDropTargetWantsFile( ev.target ) ) {
1817 return;
1818 }
1819 if ( ev.defaultPrevented ) {
1820 return;
1821 }
1822 ev.preventDefault();
1823 if ( ev.dataTransfer ) {
1824 ev.dataTransfer.dropEffect = 'copy';
1825 }
1826 }, false );
1827 document.addEventListener( 'drop', function ( ev ) {
1828 if ( ! bridgeHasFiles( ev ) ) {
1829 return;
1830 }
1831 if ( bridgeDropTargetWantsFile( ev.target ) ) {
1832 return;
1833 }
1834 if ( ev.defaultPrevented ) {
1835 return;
1836 }
1837 ev.preventDefault();
1838 ev.stopPropagation();
1839 var files = [];
1840 if ( ev.dataTransfer && ev.dataTransfer.files ) {
1841 for ( var i = 0; i < ev.dataTransfer.files.length; i++ ) {
1842 files.push( ev.dataTransfer.files[ i ] );
1843 }
1844 }
1845 if ( files.length === 0 ) {
1846 return;
1847 }
1848 try {
1849 window.parent.postMessage(
1850 {
1851 type: 'os-file-drop',
1852 files: files,
1853 x: ev.clientX,
1854 y: ev.clientY,
1855 },
1856 window.location.origin
1857 );
1858 } catch ( err ) { /* cross-origin parent; swallow */ }
1859 }, false );
1860
1861 /*
1862 * Drag-hover forwarder. Native drag events don't cross iframe
1863 * boundaries, so when the user holds ANY drag (an OS file, an
1864 * image lifted off another admin page, a text selection) over
1865 * this window, the parent shell has no idea the window is being
1866 * hovered. Forward a throttled, payload-free heartbeat so the
1867 * shell's focus-on-drag-hover module
1868 * (`src/drag/focus-window-on-drag-hover.ts`) can raise this
1869 * window after its dwell. Purely observational — no
1870 * `preventDefault()`, no interference with in-page drop zones.
1871 * The parent identifies the hovered window from the message
1872 * source, so no coordinates travel.
1873 *
1874 * Sentinel-guarded: the standalone bridge bundle
1875 * (`iframe-bridge-standalone.ts`) installs the same forwarder,
1876 * and unlike the drop forwarder above there is no
1877 * `defaultPrevented` handshake to dedupe a double install.
1878 */
1879 if ( ! window.__openStationDragHoverForwarderInstalled ) {
1880 window.__openStationDragHoverForwarderInstalled = true;
1881 var dragHoverLastSent = 0;
1882 document.addEventListener( 'dragover', function ( ev ) {
1883 var now = Date.now();
1884 if ( now - dragHoverLastSent < 150 ) {
1885 return;
1886 }
1887 dragHoverLastSent = now;
1888 try {
1889 window.parent.postMessage(
1890 {
1891 type: 'os-drag-hover',
1892 payloadType: bridgeHasFiles( ev ) ? 'os-file' : 'external',
1893 },
1894 window.location.origin
1895 );
1896 } catch ( err ) { /* cross-origin parent; swallow */ }
1897 }, true );
1898 }
1899
1900 /*
1901 * Pointer forwarder — OPT-IN, off by default.
1902 *
1903 * Pointer events don't cross iframe boundaries, so the parent
1904 * shell goes blind to the cursor the moment it enters a window.
1905 * Anything in the shell that needs to know where the mouse
1906 * actually is while it's over window content — today, the
1907 * Mio's gaze (`src/mio/pointer.ts`) — gets a throttled
1908 * stream of this frame's client coordinates and rebases them
1909 * through the iframe element's own rect.
1910 *
1911 * Strictly opt-in: the parent posts
1912 * `os-pointer-track { enabled: true }` when a consumer
1913 * starts, and `{ enabled: false }` when the last one stops. A
1914 * shell with no consumer never turns this on and pays nothing.
1915 * `os-bridge-ready` (emitted at the end of this script,
1916 * i.e. on every navigation) is the parent's cue to re-arm a
1917 * freshly-loaded frame.
1918 *
1919 * Coordinates only — no target element, no event object, nothing
1920 * about the page content. Purely observational: passive listener,
1921 * no `preventDefault()`.
1922 *
1923 * Sentinel-guarded: the standalone bridge bundle
1924 * (`iframe-bridge-standalone.ts`) installs the same forwarder.
1925 */
1926 if ( ! window.__openStationPointerForwarderInstalled ) {
1927 window.__openStationPointerForwarderInstalled = true;
1928 var pointerTrackOn = false;
1929 var pointerLastSent = 0;
1930 window.addEventListener( 'message', function ( e ) {
1931 if ( e.origin !== window.location.origin ) return;
1932 if ( ! e.data || e.data.type !== 'os-pointer-track' ) return;
1933 pointerTrackOn = !! e.data.enabled;
1934 } );
1935 document.addEventListener( 'pointermove', function ( ev ) {
1936 if ( ! pointerTrackOn ) return;
1937 var now = Date.now();
1938 // ~25 Hz. The consumer interpolates; a faster stream buys
1939 // nothing visible and costs a postMessage per mouse move.
1940 if ( now - pointerLastSent < 40 ) return;
1941 pointerLastSent = now;
1942 try {
1943 window.parent.postMessage(
1944 {
1945 type: 'os-pointer-move',
1946 x: ev.clientX,
1947 y: ev.clientY
1948 },
1949 window.location.origin
1950 );
1951 } catch ( err ) { /* cross-origin parent; swallow */ }
1952 }, { capture: true, passive: true } );
1953 }
1954
1955 /*
1956 * Cmd+K / Ctrl+K forwarder — single-press, unconditional.
1957 *
1958 * Native keydown events don't cross iframe boundaries. Inside a
1959 * chromeless admin page we want exactly ONE command palette: the
1960 * desktop shell's. WordPress's own `core/commands` palette is
1961 * harvested by `__wpdHarvestCommands` below and re-surfaced in the
1962 * shell palette, so there's no reason to ever let the in-page palette
1963 * take the keystroke.
1964 *
1965 * Capture phase + `stopImmediatePropagation` so we win the race
1966 * against Gutenberg / TinyMCE / plugin handlers bound to the same
1967 * shortcut. Shift/Alt modifiers pass through so user shortcuts using
1968 * those combos keep working.
1969 */
1970 document.addEventListener( 'keydown', function ( e ) {
1971 if ( ! ( e.metaKey || e.ctrlKey ) ) return;
1972 if ( e.key !== 'k' && e.key !== 'K' ) return;
1973 if ( e.shiftKey || e.altKey ) return;
1974
1975 e.preventDefault();
1976 e.stopImmediatePropagation();
1977
1978 try {
1979 window.parent.postMessage(
1980 { type: 'os-palette-cycle' },
1981 window.location.origin
1982 );
1983 } catch ( err ) { /* cross-origin parent; swallow */ }
1984 }, true );
1985
1986 /*
1987 * Command harvester — bridges `wp.data.select('core/commands')` to
1988 * the parent shell.
1989 *
1990 * On `os-commands-subscribe` from the parent, subscribe to
1991 * the `core/commands` store and post `os-commands-list` on
1992 * every change (de-duplicated). On `os-commands-invoke`, run
1993 * the original callback inside this iframe — the parent fires this
1994 * when the user selects a proxied command from the shell palette.
1995 *
1996 * Commands are classified by dry-invoking their callback inside a
1997 * `window.location`-intercept sandbox: pure-navigation callbacks
1998 * are flagged `navigate` (with the captured URL) so the parent can
1999 * open a new desktop window instead of navigating this iframe out
2000 * of chromeless mode. Everything else is `action` and proxies back
2001 * into this iframe on user selection.
2002 */
2003 var __wpdCommandsSubscribed = false;
2004 var __wpdCommandsLastPayload = '';
2005 var __wpdCommandsDebounceId = null;
2006 var __wpdCommandsOrigin = window.location.origin;
2007 // Cache per command name so the `window.location`-intercept
2008 // sandbox only runs once per command. Re-classifying on every
2009 // store tick would repeatedly fire side-effectful action
2010 // callbacks (preference toggles, modal opens) — unacceptable.
2011 // Keyed by name; value is the frozen classification minus the
2012 // live `label` / `icon` (which we always re-read in case the
2013 // command updated its own metadata).
2014 var __wpdCommandsKindCache = Object.create( null );
2015
2016 function __wpdRenderIconElement( icon ) {
2017 if ( ! icon ) return '';
2018 if ( typeof icon === 'string' ) return '';
2019 if ( ! window.wp || ! window.wp.element || typeof window.wp.element.renderToString !== 'function' ) {
2020 return '';
2021 }
2022 try {
2023 var rendered = window.wp.element.renderToString( icon );
2024 // `@wordpress/icons` entries render as a complete `<svg>`
2025 // tag. Anything else (wrapped components, empty fragments,
2026 // strings) falls back to dashicons in the palette — we only
2027 // accept markup we can inject straight into the icon slot.
2028 if ( typeof rendered === 'string' && rendered.toLowerCase().indexOf( '<svg' ) === 0 ) {
2029 return rendered;
2030 }
2031 } catch ( _err ) { /* swallow */ }
2032 return '';
2033 }
2034
2035 function __wpdClassifyCommand( cmd ) {
2036 // Defensive defaults — a broken registry should not tank the bridge.
2037 var out = {
2038 name: String( cmd && cmd.name ? cmd.name : '' ),
2039 label: String( cmd && cmd.label ? cmd.label : '' ),
2040 icon: cmd && cmd.icon && typeof cmd.icon === 'string' ? cmd.icon : undefined,
2041 iconSvg: undefined,
2042 context: cmd && cmd.context ? String( cmd.context ) : undefined,
2043 kind: 'action',
2044 url: undefined
2045 };
2046 if ( ! cmd || typeof cmd.callback !== 'function' ) {
2047 return out;
2048 }
2049
2050 // Short-circuit on cached classifications — `renderToString` on
2051 // the React icon is expensive, and the static URL regex scan
2052 // on `callback.toString()` is pure CPU we've already paid once.
2053 var cached = __wpdCommandsKindCache[ out.name ];
2054 if ( cached ) {
2055 out.kind = cached.kind;
2056 out.url = cached.url;
2057 out.iconSvg = cached.iconSvg;
2058 return out;
2059 }
2060
2061 // Render the React icon once per command name — Gutenberg
2062 // commands ship `icon` as a `@wordpress/icons` React element
2063 // the postMessage bridge can't serialize, so we flatten it to
2064 // a static SVG string here.
2065 if ( cmd.icon && typeof cmd.icon !== 'string' ) {
2066 out.iconSvg = __wpdRenderIconElement( cmd.icon );
2067 }
2068
2069 // STATIC classification — read the callback's source text and
2070 // look for a string-literal navigation target. We deliberately
2071 // do NOT execute the callback. An earlier iteration tried a
2072 // dry-run with a `window.location` intercept sandbox, but
2073 // `Location.prototype.href` is non-configurable: the shim
2074 // silently failed, every nav callback actually navigated the
2075 // iframe, the new page re-harvested, and the cascade opened
2076 // windows forever.
2077 //
2078 // Cases caught (WP's @wordpress/core-commands callbacks are
2079 // all of this shape):
2080 // document.location.href = 'url'
2081 // window.location.href = "url"
2082 // location.href = `url`
2083 // location.assign( 'url' )
2084 // location.replace( 'url' )
2085 //
2086 // Computed URLs (template-literal interpolation, addQueryArgs
2087 // calls, variables) fall back to `action` — the user picking
2088 // them will still run the real callback inside the iframe,
2089 // which is the safe default.
2090 var src = '';
2091 try { src = Function.prototype.toString.call( cmd.callback ); } catch ( _err ) { src = ''; }
2092 var navRe = /(?:document\.location\.href|window\.location\.href|location\.href)\s*=\s*['"]([^'"$]+?)['"]/;
2093 var asgRe = /location\.(?:assign|replace)\s*\(\s*['"]([^'"$]+?)['"]\s*\)/;
2094 var mm = src.match( navRe ) || src.match( asgRe );
2095 if ( mm && mm[ 1 ] ) {
2096 try {
2097 out.url = new URL( mm[ 1 ], window.location.href ).toString();
2098 out.kind = 'navigate';
2099 } catch ( _err ) {
2100 out.kind = 'action';
2101 }
2102 }
2103 __wpdCommandsKindCache[ out.name ] = { kind: out.kind, url: out.url, iconSvg: out.iconSvg };
2104 return out;
2105 }
2106
2107 // Harvested commands accumulate here. The React harvester writes
2108 // the full list each render; `__wpdPostCommandsList` reads + posts.
2109 var __wpdLastRawCommands = [];
2110 // Name → live `callback` reference. Loader-returned commands are
2111 // NOT in `wp.data.select('core/commands').getCommands()` — the
2112 // store only exposes statically-registered entries. Without a
2113 // private cache keyed off the React harvester's most recent render,
2114 // invoking a loader command from the parent palette ("Duplicate
2115 // block", "Transform to...", pattern commands) would silently fall
2116 // through to the `getCommands()` lookup and no-op.
2117 var __wpdCommandCallbacks = Object.create( null );
2118
2119 function __wpdFinalizeCommands( raw ) {
2120 var seen = Object.create( null );
2121 var out = [];
2122 var skipped = { missing: 0, disabled: 0, dup: 0 };
2123 for ( var i = 0; i < raw.length; i++ ) {
2124 var cmd = raw[ i ];
2125 if ( ! cmd || ! cmd.name || ! cmd.label ) { skipped.missing++; continue; }
2126 if ( cmd.disabled ) { skipped.disabled++; continue; }
2127 if ( seen[ cmd.name ] ) { skipped.dup++; continue; }
2128 seen[ cmd.name ] = true;
2129 out.push( __wpdClassifyCommand( cmd ) );
2130 }
2131 return out;
2132 }
2133
2134 function __wpdHarvestCommands() {
2135 return __wpdFinalizeCommands( __wpdLastRawCommands );
2136 }
2137
2138 // React-mounted harvester. Block-level / editor-contextual commands
2139 // (tier 3 loaders like `core/block-editor/selected-block-commands`,
2140 // `core/edit-post/pattern-commands`) are React *hooks* — they call
2141 // `useSelect` internally, which only works inside a function-
2142 // component render. So we mount an invisible React tree whose
2143 // children invoke each loader's hook at render time. On every
2144 // re-render (block selection changes, entity edits, welcome guide
2145 // toggled) the effect re-posts the fresh command list to the
2146 // parent. One component per loader keeps the rules-of-hooks
2147 // contract — the hook count inside each `LoaderSlot` is fixed at
2148 // one call (plus the constant `useEffect`), so React's reconciler
2149 // is happy.
2150 var __wpdReactMounted = false;
2151 // Stashed so `__wpdUnsubscribeCommands` can tear the harvester
2152 // down when focus leaves the window — otherwise the component
2153 // keeps re-rendering on every store tick, calling `mergeAndPost`,
2154 // and posting command lists the parent drops on the floor.
2155 var __wpdReactRoot = null;
2156 var __wpdReactHost = null;
2157
2158 function __wpdMountReactHarvester() {
2159 if ( __wpdReactMounted ) return;
2160 if ( ! window.wp || ! window.wp.element || ! window.wp.data ) {
2161 return;
2162 }
2163 var el = window.wp.element;
2164 var createEl = el.createElement;
2165 var useEffect = el.useEffect;
2166 var useRef = el.useRef;
2167 var useMemo = el.useMemo;
2168 var useSelect = ( window.wp.data && window.wp.data.useSelect ) || null;
2169 if ( ! createEl || ! useSelect || ! el.createRoot || ! useRef ) {
2170 return;
2171 }
2172 __wpdReactMounted = true;
2173
2174 // Hidden mount point. Positioned off-screen + `aria-hidden` so
2175 // nothing the harvester renders (it renders null anyway) can
2176 // leak into the accessibility tree or the visible document.
2177 var host = document.createElement( 'div' );
2178 host.setAttribute( 'aria-hidden', 'true' );
2179 host.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden;pointer-events:none;left:-9999px;top:-9999px;';
2180 ( document.body || document.documentElement ).appendChild( host );
2181 __wpdReactHost = host;
2182
2183 // Shared mutable bucket — ref-based aggregation to avoid the
2184 // classic setState-inside-useEffect loop. A `setState` here
2185 // would fire a parent re-render, which would fire the loader
2186 // hook again, which returns a fresh commands array with a new
2187 // reference even when the contents are identical, which would
2188 // re-fire the effect and setState again → Maximum update
2189 // depth exceeded. Refs don't trigger renders, so the loop is
2190 // broken even when hooks churn references.
2191 var resultsBucket = { perLoader: {}, statics: [], loadersList: [] };
2192
2193 function commandsFingerprint( cmds ) {
2194 if ( ! Array.isArray( cmds ) || cmds.length === 0 ) return '';
2195 // Cheap identity — name count is enough to decide whether
2196 // to re-post. Accepts some false negatives (two different
2197 // commands sharing a name) we'll never hit in practice.
2198 var keys = new Array( cmds.length );
2199 for ( var i = 0; i < cmds.length; i++ ) {
2200 var c = cmds[ i ];
2201 keys[ i ] = c && c.name ? c.name : '';
2202 }
2203 return keys.join( '|' );
2204 }
2205
2206 function mergeAndPost() {
2207 var merged = [];
2208 var loadersList = resultsBucket.loadersList;
2209 if ( Array.isArray( loadersList ) ) {
2210 for ( var i = 0; i < loadersList.length; i++ ) {
2211 var bucket = resultsBucket.perLoader[ loadersList[ i ] ];
2212 if ( Array.isArray( bucket ) ) merged = merged.concat( bucket );
2213 }
2214 }
2215 if ( Array.isArray( resultsBucket.statics ) ) {
2216 merged = merged.concat( resultsBucket.statics );
2217 }
2218 // Refresh the callback cache off the SAME snapshot we're
2219 // about to post. Loader-returned commands close over React
2220 // state (selected block, edited entity, etc.) that's only
2221 // valid for this render pass, so rebuilding from scratch
2222 // every merge keeps invoke-from-parent honest instead of
2223 // calling a stale closure.
2224 __wpdCommandCallbacks = Object.create( null );
2225 for ( var j = 0; j < merged.length; j++ ) {
2226 var cc = merged[ j ];
2227 if ( cc && cc.name && typeof cc.callback === 'function' ) {
2228 __wpdCommandCallbacks[ cc.name ] = cc.callback;
2229 }
2230 }
2231 __wpdLastRawCommands = merged;
2232 __wpdSchedulePost();
2233 }
2234
2235 // One slot per loader. Calls the loader's hook at render time;
2236 // an effect keyed on the commands' name-fingerprint writes the
2237 // fresh list into the shared bucket and posts. Ref-based, no
2238 // setState → no re-render cascade.
2239 function LoaderSlot( props ) {
2240 var loader = props.loader;
2241 var result = null;
2242 try {
2243 result = loader.hook( { search: '' } );
2244 } catch ( _err ) {
2245 /* swallow — a buggy loader hook shouldn't take the harvester down */
2246 }
2247 var cmds = ( result && Array.isArray( result.commands ) ) ? result.commands : [];
2248 var key = useMemo( function () { return commandsFingerprint( cmds ); }, [ cmds ] );
2249
2250 useEffect( function () {
2251 resultsBucket.perLoader[ loader.name ] = cmds;
2252 mergeAndPost();
2253 }, [ key ] );
2254
2255 useEffect( function () {
2256 return function () {
2257 delete resultsBucket.perLoader[ loader.name ];
2258 mergeAndPost();
2259 };
2260 }, [] );
2261
2262 return null;
2263 }
2264
2265 function Harvester() {
2266 var loaders = useSelect( function ( s ) {
2267 var ss = s( 'core/commands' );
2268 return ( ss && typeof ss.getCommandLoaders === 'function' )
2269 ? ss.getCommandLoaders( true )
2270 : [];
2271 }, [] );
2272 var staticCmds = useSelect( function ( s ) {
2273 var ss = s( 'core/commands' );
2274 return ( ss && typeof ss.getCommands === 'function' )
2275 ? ss.getCommands( true )
2276 : [];
2277 }, [] );
2278
2279 // Track the loader-name ordering so `mergeAndPost` can emit
2280 // tier-3 in a deterministic order (React reconciliation
2281 // order = registration order = the order the user sees).
2282 var loadersNames = useMemo( function () {
2283 if ( ! Array.isArray( loaders ) ) return [];
2284 return loaders.map( function ( l ) { return l ? l.name : ''; } );
2285 }, [ loaders ] );
2286 var loadersKey = loadersNames.join( '|' );
2287 useEffect( function () {
2288 resultsBucket.loadersList = loadersNames;
2289 mergeAndPost();
2290 }, [ loadersKey ] );
2291
2292 var staticKey = useMemo( function () { return commandsFingerprint( staticCmds ); }, [ staticCmds ] );
2293 useEffect( function () {
2294 resultsBucket.statics = Array.isArray( staticCmds ) ? staticCmds : [];
2295 mergeAndPost();
2296 }, [ staticKey ] );
2297
2298 if ( ! Array.isArray( loaders ) || loaders.length === 0 ) {
2299 return null;
2300 }
2301 var children = [];
2302 for ( var i = 0; i < loaders.length; i++ ) {
2303 var loader = loaders[ i ];
2304 if ( ! loader || typeof loader.hook !== 'function' ) continue;
2305 children.push( createEl( LoaderSlot, {
2306 key: loader.name,
2307 loader: loader
2308 } ) );
2309 }
2310 return createEl( el.Fragment || 'div', null, children );
2311 }
2312
2313 try {
2314 var root = el.createRoot( host );
2315 __wpdReactRoot = root;
2316 root.render( createEl( Harvester ) );
2317 } catch ( err ) {
2318 __wpdReactMounted = false;
2319 __wpdReactRoot = null;
2320 if ( __wpdReactHost && __wpdReactHost.parentNode ) {
2321 __wpdReactHost.parentNode.removeChild( __wpdReactHost );
2322 }
2323 __wpdReactHost = null;
2324 }
2325 }
2326
2327 function __wpdUnmountReactHarvester() {
2328 if ( __wpdReactRoot ) {
2329 try { __wpdReactRoot.unmount(); } catch ( _err ) { /* swallow */ }
2330 }
2331 __wpdReactRoot = null;
2332 if ( __wpdReactHost && __wpdReactHost.parentNode ) {
2333 __wpdReactHost.parentNode.removeChild( __wpdReactHost );
2334 }
2335 __wpdReactHost = null;
2336 __wpdReactMounted = false;
2337 __wpdLastRawCommands = [];
2338 __wpdCommandCallbacks = Object.create( null );
2339 }
2340
2341 function __wpdPostCommandsList() {
2342 var list = __wpdHarvestCommands();
2343 // Cheap de-dupe — the store fires on every unrelated preference
2344 // change too, and shipping an identical payload is pure noise.
2345 // Fingerprint on `name|kind|url` keeps us sensitive to the
2346 // visible surface (name changes, navigate-vs-action flips,
2347 // destination URL changes) while skipping `JSON.stringify` of
2348 // the entire payload — label/icon churn inside a single command
2349 // is rare and re-shipping on it is harmless noise vs. a hot
2350 // path allocation cost.
2351 var key = '';
2352 for ( var k = 0; k < list.length; k++ ) {
2353 var lc = list[ k ];
2354 key += ( lc && lc.name ? lc.name : '' ) + '|'
2355 + ( lc && lc.kind ? lc.kind : '' ) + '|'
2356 + ( lc && lc.url ? lc.url : '' ) + '\n';
2357 }
2358 if ( key === __wpdCommandsLastPayload ) {
2359 return;
2360 }
2361 __wpdCommandsLastPayload = key;
2362 try {
2363 window.parent.postMessage(
2364 { type: 'os-commands-list', commands: list },
2365 __wpdCommandsOrigin
2366 );
2367 } catch ( _err ) {
2368 /* cross-origin parent (shouldn't happen for chromeless pages, but
2369 * don't let a throw break the bridge) */
2370 }
2371 }
2372
2373 function __wpdSchedulePost() {
2374 if ( __wpdCommandsDebounceId !== null ) return;
2375 __wpdCommandsDebounceId = window.setTimeout( function () {
2376 __wpdCommandsDebounceId = null;
2377 __wpdPostCommandsList();
2378 }, 60 );
2379 }
2380
2381 function __wpdSubscribeCommands() {
2382 __wpdCommandsSubscribed = true;
2383
2384 // If the React harvester is already running (focus left and
2385 // came back), the bucket still holds the latest merged list.
2386 // Reset the dedupe key so the next post actually ships, then
2387 // schedule it. The harvester itself won't re-fire its effects
2388 // just because the parent re-subscribed — React only reacts to
2389 // store changes, and the store hasn't changed. We have to
2390 // push from here.
2391 if ( __wpdReactMounted ) {
2392 __wpdCommandsLastPayload = '';
2393 __wpdSchedulePost();
2394 return;
2395 }
2396
2397 var attempts = 0;
2398 function tryBind() {
2399 if ( ! __wpdCommandsSubscribed ) return;
2400 if ( ! window.wp || ! window.wp.data || typeof window.wp.data.subscribe !== 'function' ) {
2401 if ( attempts++ < 40 ) {
2402 window.setTimeout( tryBind, 150 );
2403 }
2404 return;
2405 }
2406 // Mount the React harvester — tier 3 loaders are hooks and
2407 // need a legal render context to execute. On every re-render
2408 // the component's effect calls `__wpdSchedulePost` with the
2409 // fresh merged list, so we don't need a separate
2410 // `wp.data.subscribe` callback.
2411 __wpdMountReactHarvester();
2412 }
2413 tryBind();
2414 }
2415
2416 function __wpdUnsubscribeCommands() {
2417 __wpdCommandsSubscribed = false;
2418 __wpdCommandsLastPayload = '';
2419 if ( __wpdCommandsDebounceId !== null ) {
2420 try { window.clearTimeout( __wpdCommandsDebounceId ); } catch ( _err ) { /* swallow */ }
2421 __wpdCommandsDebounceId = null;
2422 }
2423 // Fully tear down the React harvester. Keeping it mounted in
2424 // the background wastes CPU: every store tick re-renders the
2425 // loader hooks, which rebuild the callback cache and post to
2426 // the parent (who drops the message because this window isn't
2427 // the subscribed one). On re-subscribe we remount from scratch.
2428 __wpdUnmountReactHarvester();
2429 }
2430
2431 function __wpdInvokeCommand( name ) {
2432 // Primary lookup — the React harvester's latest snapshot. This
2433 // covers loader-returned commands (Duplicate block, Transform
2434 // to, pattern commands) that never appear in the static
2435 // `getCommands()` list.
2436 var cb = __wpdCommandCallbacks[ name ];
2437 if ( typeof cb === 'function' ) {
2438 try {
2439 cb( { close: function () {} } );
2440 } catch ( _err ) {
2441 /* swallow — a plugin command callback that throws shouldn't break the bridge */
2442 }
2443 return;
2444 }
2445 // Fallback — statically registered commands that never passed
2446 // through the harvester (registered after the last render).
2447 if ( ! window.wp || ! window.wp.data ) {
2448 return;
2449 }
2450 var sel = null;
2451 try { sel = window.wp.data.select( 'core/commands' ); } catch ( _err ) { return; }
2452 if ( ! sel || typeof sel.getCommands !== 'function' ) return;
2453 var raw;
2454 try { raw = sel.getCommands(); } catch ( _err ) { return; }
2455 if ( ! raw ) return;
2456 for ( var i = 0; i < raw.length; i++ ) {
2457 if ( raw[ i ] && raw[ i ].name === name && typeof raw[ i ].callback === 'function' ) {
2458 try {
2459 raw[ i ].callback( { close: function () {} } );
2460 } catch ( _err ) {
2461 /* swallow — see note in primary path above */
2462 }
2463 return;
2464 }
2465 }
2466 }
2467
2468 // Attach the listener BEFORE the bridge-ready ping so a subscribe
2469 // posted synchronously in response is guaranteed to land.
2470 window.addEventListener( 'message', function ( e ) {
2471 if ( e.origin !== __wpdCommandsOrigin ) return;
2472 if ( ! e.data || typeof e.data.type !== 'string' ) return;
2473 if ( e.data.type === 'os-commands-subscribe' ) {
2474 __wpdSubscribeCommands();
2475 } else if ( e.data.type === 'os-commands-unsubscribe' ) {
2476 __wpdUnsubscribeCommands();
2477 } else if ( e.data.type === 'os-commands-invoke' && typeof e.data.name === 'string' ) {
2478 __wpdInvokeCommand( e.data.name );
2479 }
2480 } );
2481
2482 // Handshake: tell the parent we're ready so it can (re)send any
2483 // subscribe that was dispatched before this listener attached.
2484 // Without this ping, a subscribe posted during iframe navigation
2485 // arrives at a context whose message listener isn't installed yet
2486 // and is silently dropped — the symptom is an empty palette even
2487 // though `wp.data.select('core/commands')` is perfectly happy.
2488 try {
2489 window.parent.postMessage(
2490 { type: 'os-bridge-ready' },
2491 __wpdCommandsOrigin
2492 );
2493 } catch ( _err ) {
2494 /* parent gone or cross-origin — bridge handshake will retry on next load */
2495 }
2496
2497 /*
2498 * ` / Shift+` forwarder — window switcher.
2499 *
2500 * Bare backtick with no modifier. Must skip when focus is in a
2501 * text-entry element, otherwise typing ` into a block, a text
2502 * field, or TinyMCE would steal the keystroke. Non-text inputs
2503 * (checkbox, button, select) don't accept character input, so
2504 * cycling on those is fine.
2505 *
2506 * Same iframe-crossing rationale as the Cmd+K forwarder above:
2507 * native keydown doesn't reach the parent, so we postMessage.
2508 */
2509 document.addEventListener( 'keydown', function ( e ) {
2510 if ( e.ctrlKey || e.metaKey || e.altKey ) return;
2511 if ( e.code !== 'Backquote' ) return;
2512
2513 // IFRAME case catches Gutenberg: the block canvas is a nested
2514 // iframe, and Gutenberg re-dispatches cloned keydowns up to
2515 // this document for its shortcut system. Without this branch
2516 // typing ` in a block would cycle windows. Any other nested
2517 // iframe owning keyboard handling gets the same treatment.
2518 var el = document.activeElement;
2519 if ( el ) {
2520 var tag = el.tagName;
2521 if ( tag === 'IFRAME' ) return;
2522 if ( tag === 'TEXTAREA' ) return;
2523 if ( tag === 'INPUT' ) {
2524 var type = ( el.type || '' ).toLowerCase();
2525 var textTypes = [
2526 'text', 'search', 'url', 'email', 'password',
2527 'tel', 'number', 'date', 'datetime-local',
2528 'month', 'week', 'time'
2529 ];
2530 if ( textTypes.indexOf( type ) !== -1 ) return;
2531 }
2532 if ( el.isContentEditable ) return;
2533 }
2534
2535 e.preventDefault();
2536 e.stopImmediatePropagation();
2537
2538 try {
2539 window.parent.postMessage(
2540 {
2541 type: 'os-window-switch',
2542 direction: e.shiftKey ? 'prev' : 'next'
2543 },
2544 window.location.origin
2545 );
2546 } catch ( err ) { /* cross-origin parent; swallow */ }
2547 }, true );
2548
2549 // Skip if the standalone iframe-bridge bundle already wired
2550 // screen-meta hoisting on this page. Two bridges racing to read
2551 // `aria-expanded` and reflect state would double-fire the
2552 // `os-screen-meta-state` message and flicker the
2553 // title-bar buttons.
2554 if ( window.__openStationScreenMetaInstalled ) {
2555 return;
2556 }
2557 window.__openStationScreenMetaInstalled = true;
2558
2559 // Real screen options render form controls (column toggles, a
2560 // per-page input, custom settings). An empty wrap should not
2561 // surface a dead gear button.
2562 function hasScreenOptionsContent() {
2563 var wrap = document.getElementById( 'screen-options-wrap' );
2564 // WP always renders a nonce hidden input and an "Apply" submit
2565 // inside the wrap, so match only interactive option controls
2566 // (toggles, per-page, radios, selects) — never that always-
2567 // present scaffolding — or an empty panel reads as non-empty.
2568 return !! wrap && !! wrap.querySelector( 'input:not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]), select, textarea' );
2569 }
2570 // A help tab registered with empty content + no callback still
2571 // produces #contextual-help-link but an empty panel. Require some
2572 // non-whitespace tab/sidebar text before announcing the button.
2573 function hasHelpContent() {
2574 var wrap = document.getElementById( 'contextual-help-wrap' );
2575 if ( ! wrap ) {
2576 return false;
2577 }
2578 var panelEls = wrap.querySelectorAll( '.help-tab-content, .contextual-help-sidebar' );
2579 for ( var i = 0; i < panelEls.length; i++ ) {
2580 if ( ( panelEls[ i ].textContent || '' ).trim() !== '' ) {
2581 return true;
2582 }
2583 }
2584 return false;
2585 }
2586
2587 var links = document.getElementById( 'screen-meta-links' );
2588 var screenOptionsBtn = links ? document.getElementById( 'show-settings-link' ) : null;
2589 var helpBtn = links ? document.getElementById( 'contextual-help-link' ) : null;
2590 var panels = [];
2591 if ( screenOptionsBtn && hasScreenOptionsContent() ) {
2592 panels.push( 'screen-options' );
2593 }
2594 if ( helpBtn && hasHelpContent() ) {
2595 panels.push( 'help' );
2596 }
2597
2598 var origin = window.location.origin;
2599
2600 // ALWAYS announce — including an empty array — so the parent removes
2601 // stale gear/Help buttons when this page (e.g. after an in-place
2602 // same-slug navigation) has no screen meta. addScreenMetaButtons()
2603 // clears then repopulates, so an empty array removes everything.
2604 window.parent.postMessage( {
2605 type: 'os-screen-meta',
2606 panels: panels
2607 }, origin );
2608
2609 if ( panels.length === 0 ) {
2610 return;
2611 }
2612
2613 function getOpenPanel() {
2614 if ( screenOptionsBtn && screenOptionsBtn.getAttribute( 'aria-expanded' ) === 'true' ) {
2615 return 'screen-options';
2616 }
2617 if ( helpBtn && helpBtn.getAttribute( 'aria-expanded' ) === 'true' ) {
2618 return 'help';
2619 }
2620 return null;
2621 }
2622
2623 function reportState() {
2624 window.parent.postMessage( {
2625 type: 'os-screen-meta-state',
2626 open: getOpenPanel()
2627 }, origin );
2628 }
2629
2630 reportState();
2631
2632 var observer = new MutationObserver( reportState );
2633 if ( screenOptionsBtn ) {
2634 observer.observe( screenOptionsBtn, { attributes: true, attributeFilter: [ 'aria-expanded' ] } );
2635 }
2636 if ( helpBtn ) {
2637 observer.observe( helpBtn, { attributes: true, attributeFilter: [ 'aria-expanded' ] } );
2638 }
2639
2640 // WP's close() animates and shares #screen-meta between both panels,
2641 // so racing two animated clicks hides the panel that just opened.
2642 // Jump the other panel to its closed end state synchronously instead.
2643 function forceClose( button ) {
2644 if ( ! button || button.getAttribute( 'aria-expanded' ) !== 'true' ) {
2645 return;
2646 }
2647 var panelId = button.getAttribute( 'aria-controls' );
2648 var panel = panelId ? document.getElementById( panelId ) : null;
2649 if ( ! panel ) {
2650 return;
2651 }
2652 if ( window.jQuery ) {
2653 window.jQuery( panel ).stop( true, false );
2654 }
2655 panel.style.display = 'none';
2656 panel.classList.add( 'hidden' );
2657 if ( panel.parentNode instanceof HTMLElement ) {
2658 panel.parentNode.style.display = 'none';
2659 }
2660 button.classList.remove( 'screen-meta-active' );
2661 button.setAttribute( 'aria-expanded', 'false' );
2662 var toggles = document.querySelectorAll( '.screen-meta-toggle' );
2663 for ( var i = 0; i < toggles.length; i++ ) {
2664 toggles[ i ].style.visibility = '';
2665 }
2666 }
2667
2668 /* -----------------------------------------------------------------
2669 * Broadcast receiver — iframe side.
2670 *
2671 * The parent shell publishes broadcasts via
2672 * `wp.os.broadcast(topic, payload)` (see `src/broadcast.ts`).
2673 * It posts `{ type: 'os-broadcast', topic, payload }` to
2674 * every open iframe. Here we re-dispatch that as a CustomEvent
2675 * on the iframe's own document so admin pages can subscribe with
2676 * plain `document.addEventListener( 'os-broadcast', cb )`
2677 * — no extra script handle required.
2678 *
2679 * Iframe-side admin code can also publish UPSTREAM by posting
2680 * the same shape to `window.parent`; the parent's
2681 * `installBroadcastReceiver()` re-broadcasts to every other
2682 * iframe + native window.
2683 * ----------------------------------------------------------------- */
2684 window.addEventListener( 'message', function ( e ) {
2685 if ( e.origin !== origin ) {
2686 return;
2687 }
2688 if ( ! e.data || e.data.type !== 'os-broadcast' ) {
2689 return;
2690 }
2691 try {
2692 document.dispatchEvent( new CustomEvent( 'os-broadcast', {
2693 detail: { topic: e.data.topic, payload: e.data.payload }
2694 } ) );
2695 } catch ( _err ) { /* old browser without CustomEvent ctor — ignore */ }
2696 } );
2697
2698 /* -----------------------------------------------------------------
2699 * Soft-reload — iframe-side default handler.
2700 *
2701 * When a `os.<post_type>.changed` broadcast fires AND the
2702 * current iframe is on a known list page for that post type, we
2703 * fetch the current URL and replace the iframe's `#wpbody-content`
2704 * in place. The user sees the new state of the list — restored
2705 * post appears, deleted media disappears — without the WP loading
2706 * spinner that `location.reload()` would show.
2707 *
2708 * Single-edit pages (`post.php`, `post-new.php`, the HPOS order
2709 * editor) are deliberately NOT matched: replacing their body would
2710 * destroy any unsaved Gutenberg/classic-editor state. Plugins that
2711 * want specific behaviour for those pages can subscribe to the
2712 * same topic on `document` and handle it themselves.
2713 *
2714 * Matching is generic: the current page's "list type" is derived
2715 * from the URL (`edit.php` → its `post_type` param or `post`,
2716 * `upload.php` → `attachment`, `edit-comments.php` → `comment`)
2717 * and compared against the `<type>` captured from any
2718 * `os.<type>.changed` topic — so every custom post
2719 * type's `edit.php?post_type=X` screen participates with zero
2720 * per-type code. Non-`edit.php` list screens (e.g. WooCommerce's
2721 * HPOS `admin.php?page=wc-orders`) are covered by declarative
2722 * extra rules, filterable server-side via
2723 * `openstation_soft_reload_rules`.
2724 *
2725 * The fetch carries a custom header so a later phase can serve a
2726 * minimal partial response if we want to optimise; for now WP
2727 * returns the full admin page and we just pluck the body.
2728 *
2729 * Most WP list-table JS delegates on `document`/`body` and
2730 * survives the swap. The inline editors don't, so
2731 * `_openstationReinitListTables()` below re-runs Core's init
2732 * entry points afterwards. A page needing more than that should
2733 * listen for `os-soft-reloaded`, which fires after that re-init.
2734 * ----------------------------------------------------------------- */
2735 var OPENSTATION_SOFT_RELOAD_EXTRAS = /*__OPENSTATION_SOFT_RELOAD_EXTRAS__*/;
2736
2737 function _openstationEndsWith( s, suffix ) { return s.lastIndexOf( suffix ) === s.length - suffix.length; }
2738
2739 function _openstationListType() {
2740 if ( _openstationEndsWith( location.pathname, '/wp-admin/edit.php' ) ) {
2741 return new URLSearchParams( location.search ).get( 'post_type' ) || 'post';
2742 }
2743 if ( _openstationEndsWith( location.pathname, '/wp-admin/upload.php' ) ) {
2744 return 'attachment';
2745 }
2746 if ( _openstationEndsWith( location.pathname, '/wp-admin/edit-comments.php' ) ) {
2747 return 'comment';
2748 }
2749 if ( _openstationEndsWith( location.pathname, '/wp-admin/plugins.php' ) ) {
2750 return 'plugin';
2751 }
2752 // plugin-install.php is intentionally not a soft-reload target.
2753 // Reloading that page mid-session would discard the user's search
2754 // results or reset an in-progress install. The page still emits
2755 // plugin.changed (via notifyPluginInstall below); it just doesn't
2756 // reload itself in response to one.
2757 return null;
2758 }
2759
2760 function _openstationMatchesExtraRule( rule ) {
2761 if ( ! rule || ! rule.path ) {
2762 return false;
2763 }
2764 if ( ! _openstationEndsWith( location.pathname, '/wp-admin/' + rule.path ) ) {
2765 return false;
2766 }
2767 var params = new URLSearchParams( location.search );
2768 if ( rule.query ) {
2769 for ( var key in rule.query ) {
2770 if ( ! Object.prototype.hasOwnProperty.call( rule.query, key ) ) {
2771 continue;
2772 }
2773 if ( params.get( key ) !== String( rule.query[ key ] ) ) {
2774 return false;
2775 }
2776 }
2777 }
2778 if ( rule.queryAbsent ) {
2779 for ( var i = 0; i < rule.queryAbsent.length; i++ ) {
2780 if ( params.has( rule.queryAbsent[ i ] ) ) {
2781 return false;
2782 }
2783 }
2784 }
2785 return true;
2786 }
2787
2788 function _openstationSoftReloadTopicMatches( topic ) {
2789 var m = /^os\.(.+)\.changed$/.exec( topic );
2790 if ( m && m[ 1 ] === _openstationListType() ) {
2791 return true;
2792 }
2793 for ( var i = 0; i < OPENSTATION_SOFT_RELOAD_EXTRAS.length; i++ ) {
2794 var rule = OPENSTATION_SOFT_RELOAD_EXTRAS[ i ];
2795 if ( rule && rule.topic === topic && _openstationMatchesExtraRule( rule ) ) {
2796 return true;
2797 }
2798 }
2799 return false;
2800 }
2801
2802 var _openstationSoftReloadInFlight = false;
2803 var _openstationSoftReloadQueued = false;
2804
2805 /*
2806 * Re-init Core's list-table editors after a soft reload.
2807 *
2808 * Core's inline editors bind to elements inside `#wpbody-content`
2809 * instead of delegating on `document`: `#the-list` for Quick Edit
2810 * (inline-edit-post.js, inline-edit-tax.js), `#doaction` for Bulk
2811 * Edit, `#the-comment-list` for the comment inline editors. The
2812 * swap above throws those elements away, so the buttons keep
2813 * rendering and stop working.
2814 *
2815 * Only re-run an init whose every binding lands inside the
2816 * replaced subtree — then the fresh DOM gets it exactly once and
2817 * nothing accumulates. `setCommentsList()` fails that test and is
2818 * NOT called here: it re-runs `wpList`, whose `process()` binds on
2819 * `document` (which survives), so each call stacks another set of
2820 * comment row-action handlers and one Approve click ends up firing
2821 * N moderation requests. Those handlers were never broken by the
2822 * swap; only the closure state behind them goes stale, which costs
2823 * a stale total count until the next reload.
2824 *
2825 * Also left alone: `common.js`'s empty-bulk-action guard and
2826 * search-box mousedown, and `$.table_hotkeys` (comment moderation
2827 * shortcuts stop navigating; re-running it would double-register).
2828 * All degraded rather than dead, and each would mean copying
2829 * dozens of lines of Core in here.
2830 */
2831 function _openstationReinitListTables() {
2832 var $ = window.jQuery;
2833 if ( ! $ ) {
2834 return;
2835 }
2836
2837 /*
2838 * Mobile row expander. `common.js` binds it per-`tbody`, all
2839 * of which we just replaced. Narrow windows are the norm in
2840 * the shell, so it is often the only row affordance on screen.
2841 *
2842 * Per-`tbody` on purpose. Delegating on the now-surviving
2843 * `#wpbody-content` would stack with Core's own binding on
2844 * first load and toggle the row twice, back to closed.
2845 */
2846 $( '#wpbody-content tbody' ).on( 'click', '.toggle-row', function () {
2847 $( this ).closest( 'tr' ).toggleClass( 'is-expanded' );
2848 } );
2849
2850 if ( document.getElementById( 'the-list' ) ) {
2851 try {
2852 if ( window.inlineEditPost && typeof window.inlineEditPost.init === 'function' ) {
2853 window.inlineEditPost.init();
2854 }
2855 } catch ( err ) { _openstationWarnReinit( 'inline-edit-post', err ); }
2856 try {
2857 if ( window.inlineEditTax && typeof window.inlineEditTax.init === 'function' ) {
2858 window.inlineEditTax.init();
2859 }
2860 } catch ( err ) { _openstationWarnReinit( 'inline-edit-tax', err ); }
2861 }
2862
2863 if ( document.getElementById( 'the-comment-list' ) && window.commentReply ) {
2864 try {
2865 if ( typeof window.commentReply.init === 'function' ) {
2866 window.commentReply.init();
2867 }
2868 } catch ( err ) { _openstationWarnReinit( 'comment-reply', err ); }
2869 try {
2870 /*
2871 * Quick Edit / Reply / Edit on a comment row.
2872 * edit-comments.js binds this in its own doc-ready
2873 * rather than in `commentReply.init`, so there is
2874 * nothing to re-call. Mirror Core's handler.
2875 */
2876 $( '#the-comment-list' ).on( 'click', '.comment-inline', function () {
2877 var $el = $( this ),
2878 action = 'replyto';
2879
2880 if ( 'undefined' !== typeof $el.data( 'action' ) ) {
2881 action = $el.data( 'action' );
2882 }
2883
2884 $( this ).attr( 'aria-expanded', 'true' );
2885 window.commentReply.open( $el.data( 'commentId' ), $el.data( 'postId' ), action );
2886 } );
2887 } catch ( err ) { _openstationWarnReinit( 'comment-inline', err ); }
2888 }
2889 }
2890
2891 /*
2892 * One failing re-init must not take the others, or the
2893 * `os-soft-reloaded` listeners after them, down with it. Warn
2894 * rather than swallow: a silent catch here looks exactly like the
2895 * bug this function exists to fix.
2896 */
2897 function _openstationWarnReinit( which, err ) {
2898 if ( window.console && window.console.warn ) {
2899 window.console.warn( '[openstation] soft-reload re-init failed for ' + which + ':', err );
2900 }
2901 }
2902
2903 function _openstationSoftReload() {
2904 if ( _openstationSoftReloadInFlight ) {
2905 _openstationSoftReloadQueued = true;
2906 return;
2907 }
2908 _openstationSoftReloadInFlight = true;
2909 fetch( location.href, {
2910 credentials: 'same-origin',
2911 cache: 'no-cache',
2912 headers: { 'X-WP-Desktop-Soft-Reload': '1' }
2913 } ).then( function ( r ) {
2914 if ( ! r.ok ) throw new Error( 'soft-reload fetch failed: ' + r.status );
2915 return r.text();
2916 } ).then( function ( html ) {
2917 var doc = new DOMParser().parseFromString( html, 'text/html' );
2918 var fresh = doc.querySelector( '#wpbody-content' );
2919 var live = document.querySelector( '#wpbody-content' );
2920 if ( ! fresh || ! live ) {
2921 /* Markup we expected isn't there — admin pages we
2922 * don't recognise (or core changes the structure).
2923 * Don't reload; let the iframe stay as it is rather
2924 * than show a spinner the user told us not to. */
2925 return;
2926 }
2927 /*
2928 * Swap the CONTENTS of `#wpbody-content`, keeping the
2929 * node. Keeping it preserves handlers delegated on it,
2930 * which is where `common.js` puts the row-actions focus
2931 * reveal. Core emits the container as a bare
2932 * `<div id="wpbody-content">`, so the only thing this
2933 * discards is any attribute a plugin added to `fresh`.
2934 */
2935 live.replaceChildren.apply( live, Array.prototype.slice.call( fresh.childNodes ) );
2936 _openstationReinitListTables();
2937 try {
2938 document.dispatchEvent( new CustomEvent( 'os-soft-reloaded' ) );
2939 } catch ( _err ) {}
2940 /* Some WP scripts re-init on DOMContentLoaded only — let
2941 * pages opt-in to a re-init by listening to the event
2942 * above. We intentionally do NOT re-fire DOMContentLoaded;
2943 * that's almost always wrong (double-init of jQuery/WP). */
2944 } ).catch( function ( err ) {
2945 /* Network error — leave the iframe untouched. The user's
2946 * next manual interaction will refresh state, and the
2947 * next broadcast will retry. */
2948 if ( window.console && window.console.warn ) {
2949 window.console.warn( '[openstation] soft-reload skipped:', err );
2950 }
2951 } ).then( function () {
2952 _openstationSoftReloadInFlight = false;
2953 if ( _openstationSoftReloadQueued ) {
2954 _openstationSoftReloadQueued = false;
2955 _openstationSoftReload();
2956 }
2957 } );
2958 }
2959
2960 document.addEventListener( 'os-broadcast', function ( e ) {
2961 var detail = e.detail || {};
2962 var topic = detail.topic;
2963 if ( ! topic ) return;
2964 if ( _openstationSoftReloadTopicMatches( topic ) ) {
2965 _openstationSoftReload();
2966 }
2967 } );
2968
2969 window.addEventListener( 'message', function( e ) {
2970 if ( e.origin !== origin ) {
2971 return;
2972 }
2973 if ( ! e.data || e.data.type !== 'os-toggle-panel' ) {
2974 return;
2975 }
2976 var target = null;
2977 if ( e.data.panel === 'screen-options' && screenOptionsBtn ) {
2978 target = screenOptionsBtn;
2979 } else if ( e.data.panel === 'help' && helpBtn ) {
2980 target = helpBtn;
2981 }
2982 if ( ! target ) {
2983 return;
2984 }
2985 if ( target.getAttribute( 'aria-expanded' ) !== 'true' ) {
2986 var other = target === screenOptionsBtn ? helpBtn : screenOptionsBtn;
2987 forceClose( other );
2988 }
2989 target.click();
2990 } );
2991
2992 /* -----------------------------------------------------------------
2993 * Connection bridge — iframe side.
2994 *
2995 * Plugins call `wp.os.iframe.publish(topic, payload)` /
2996 * `subscribe(topic, cb)` / `onConnection(cb)` to talk to a parent-
2997 * side `wp.os.connect()` caller. The shell only routes;
2998 * topic semantics are plugin-defined.
2999 *
3000 * Connections are tracked locally so `onConnection` can fire when
3001 * the parent opens a new channel (typical use: start emitting
3002 * heavy events only after at least one consumer subscribed). Each
3003 * connection carries a topic-allowlist negotiated at handshake
3004 * time — wildcard ('*') subscribers see everything.
3005 * ----------------------------------------------------------------- */
3006 var _wpdConnections = {};
3007 var _wpdConnectionListeners = [];
3008 var _wpdSubs = {}; // topic → [cb, ...]
3009 var _wpdChannelSubs = {}; // channel → [cb, ...] (window-channel API)
3010 var _wpdParentOrigin = window.location.origin;
3011 var _wpdWindowId = null; // host window id, from the handshake
3012 var _wpdWindowIdWaiters = []; // pending whenWindowId() resolvers
3013
3014 /* Stash the host window's id (the parent's handshake carries
3015 * `targetWindowId`) and flush any `whenWindowId()` waiters. Same
3016 * contract as `assets/js/iframe-bridge.js`. */
3017 function _wpdSetWindowId( id ) {
3018 if ( ! id || _wpdWindowId === id ) {
3019 return;
3020 }
3021 _wpdWindowId = id;
3022 var waiters = _wpdWindowIdWaiters.splice( 0 );
3023 for ( var i = 0; i < waiters.length; i++ ) {
3024 try {
3025 waiters[ i ]( id );
3026 } catch ( _err ) { /* swallow */ }
3027 }
3028 }
3029
3030 function _wpdEmitToParent( connectionId, topic, payload ) {
3031 try {
3032 window.parent.postMessage( {
3033 type: 'os-bridge-publish',
3034 connectionId: connectionId,
3035 topic: topic,
3036 payload: payload
3037 }, _wpdParentOrigin );
3038 } catch ( _err ) { /* parent gone */ }
3039 }
3040
3041 window.addEventListener( 'message', function ( ev ) {
3042 if ( ev.origin !== _wpdParentOrigin ) {
3043 return;
3044 }
3045 var data = ev && ev.data;
3046 if ( ! data || typeof data !== 'object' || typeof data.type !== 'string' ) {
3047 return;
3048 }
3049
3050 if ( data.type === 'os-bridge-beforeunload-query' ) {
3051 var prevent = false;
3052 var msg = '';
3053
3054 function shimReturnValue( ev ) {
3055 Object.defineProperty( ev, 'returnValue', {
3056 get: function() { return this._returnValue || ''; },
3057 set: function( v ) { this._returnValue = v; }
3058 } );
3059 }
3060
3061 function checkPrevent( ev, result ) {
3062 var hasRes = typeof result === 'string' && result !== '';
3063 var hasRetVal = typeof ev.returnValue === 'string' && ev.returnValue !== '';
3064 if ( ev.defaultPrevented || hasRes || hasRetVal ) {
3065 prevent = true;
3066 if ( hasRes ) {
3067 msg = result;
3068 } else if ( hasRetVal ) {
3069 msg = ev.returnValue;
3070 }
3071 }
3072 }
3073
3074 var unloadEvent;
3075 try {
3076 unloadEvent = new Event( 'beforeunload', { cancelable: true } );
3077 } catch ( _err ) {
3078 unloadEvent = document.createEvent( 'Event' );
3079 unloadEvent.initEvent( 'beforeunload', false, true );
3080 }
3081 shimReturnValue( unloadEvent );
3082
3083 if ( typeof window.onbeforeunload === 'function' ) {
3084 var res = window.onbeforeunload( unloadEvent );
3085 checkPrevent( unloadEvent, res );
3086 }
3087 if ( ! prevent ) {
3088 var dispatchEvent;
3089 try {
3090 dispatchEvent = new Event( 'beforeunload', { cancelable: true } );
3091 } catch ( _err ) {
3092 dispatchEvent = document.createEvent( 'Event' );
3093 dispatchEvent.initEvent( 'beforeunload', false, true );
3094 }
3095 shimReturnValue( dispatchEvent );
3096 window.dispatchEvent( dispatchEvent );
3097 checkPrevent( dispatchEvent, null );
3098 }
3099
3100 try {
3101 window.parent.postMessage( {
3102 type: 'os-bridge-beforeunload-response',
3103 prevent: prevent,
3104 message: msg
3105 }, _wpdParentOrigin );
3106 } catch ( _err ) { /* swallow */ }
3107 return;
3108 }
3109
3110 if ( data.type === 'os-bridge-handshake' && typeof data.connectionId === 'string' ) {
3111 /* The parent's handshake carries the host window id —
3112 * stash it so `wp.os.iframe.windowId` and
3113 * `whenWindowId()` can serve callers that need to know
3114 * which native window opened this iframe. */
3115 if ( typeof data.targetWindowId === 'string' && data.targetWindowId !== '' ) {
3116 _wpdSetWindowId( data.targetWindowId );
3117 }
3118 if ( _wpdConnections[ data.connectionId ] ) {
3119 /* Re-handshake on iframe-ready re-arm — no-op besides
3120 * acking again so the parent can resume. */
3121 try {
3122 window.parent.postMessage( {
3123 type: 'os-bridge-handshake-ack',
3124 connectionId: data.connectionId
3125 }, _wpdParentOrigin );
3126 } catch ( _err ) { /* swallow */ }
3127 return;
3128 }
3129 var conn = {
3130 id: data.connectionId,
3131 topics: Array.isArray( data.topics ) ? data.topics.slice() : []
3132 };
3133 _wpdConnections[ conn.id ] = conn;
3134 try {
3135 window.parent.postMessage( {
3136 type: 'os-bridge-handshake-ack',
3137 connectionId: conn.id
3138 }, _wpdParentOrigin );
3139 } catch ( _err ) { /* swallow */ }
3140 for ( var i = 0; i < _wpdConnectionListeners.length; i++ ) {
3141 try {
3142 _wpdConnectionListeners[ i ]( {
3143 id: conn.id,
3144 topics: conn.topics.slice()
3145 } );
3146 } catch ( _err ) { /* swallow listener */ }
3147 }
3148 return;
3149 }
3150
3151 if ( data.type === 'os-bridge-publish' && typeof data.topic === 'string' ) {
3152 var bucket = _wpdSubs[ data.topic ];
3153 if ( bucket ) {
3154 for ( var j = 0; j < bucket.length; j++ ) {
3155 try {
3156 bucket[ j ]( data.payload, { topic: data.topic, connectionId: data.connectionId } );
3157 } catch ( _err ) { /* swallow subscriber */ }
3158 }
3159 }
3160 var wildcard = _wpdSubs[ '*' ];
3161 if ( wildcard ) {
3162 for ( var k = 0; k < wildcard.length; k++ ) {
3163 try {
3164 wildcard[ k ]( data.payload, { topic: data.topic, connectionId: data.connectionId } );
3165 } catch ( _err ) { /* swallow */ }
3166 }
3167 }
3168 return;
3169 }
3170
3171 if ( data.type === 'os-bridge-disconnect' && typeof data.connectionId === 'string' ) {
3172 delete _wpdConnections[ data.connectionId ];
3173 return;
3174 }
3175
3176 /* Unified window-channel delivery from the parent. Fires
3177 * every `wp.os.on( channel, cb )` subscriber for the
3178 * matching channel — same protocol as
3179 * `assets/js/iframe-bridge.js`. */
3180 if ( data.type === 'os-window-send' && typeof data.channel === 'string' && data.channel !== '' ) {
3181 var meta = { channel: data.channel };
3182 var cBucket = _wpdChannelSubs[ data.channel ];
3183 if ( cBucket ) {
3184 var cBucketSnap = cBucket.slice();
3185 for ( var ci = 0; ci < cBucketSnap.length; ci++ ) {
3186 try {
3187 cBucketSnap[ ci ]( data.payload, meta );
3188 } catch ( _err ) { /* swallow */ }
3189 }
3190 }
3191 var cWildcard = _wpdChannelSubs[ '*' ];
3192 if ( cWildcard ) {
3193 var cWildcardSnap = cWildcard.slice();
3194 for ( var cw = 0; cw < cWildcardSnap.length; cw++ ) {
3195 try {
3196 cWildcardSnap[ cw ]( data.payload, meta );
3197 } catch ( _err ) { /* swallow */ }
3198 }
3199 }
3200 return;
3201 }
3202 } );
3203
3204 var iframeApi = {
3205 /**
3206 * Publish a payload under a topic. Sent to every connection
3207 * — typical case is one connection per parent caller, but
3208 * a debug console may have several at once.
3209 */
3210 publish: function ( topic, payload ) {
3211 if ( typeof topic !== 'string' || topic === '' ) {
3212 return;
3213 }
3214 var ids = Object.keys( _wpdConnections );
3215 for ( var i = 0; i < ids.length; i++ ) {
3216 _wpdEmitToParent( ids[ i ], topic, payload );
3217 }
3218 },
3219 /**
3220 * Subscribe to a topic. Returns an unsubscribe function.
3221 * Use `'*'` to receive every published payload (debugging).
3222 */
3223 subscribe: function ( topic, cb ) {
3224 if ( typeof topic !== 'string' || topic === '' || typeof cb !== 'function' ) {
3225 return function () {};
3226 }
3227 var bucket = _wpdSubs[ topic ];
3228 if ( ! bucket ) {
3229 bucket = [];
3230 _wpdSubs[ topic ] = bucket;
3231 }
3232 bucket.push( cb );
3233 return function () {
3234 var i = bucket.indexOf( cb );
3235 if ( i >= 0 ) {
3236 bucket.splice( i, 1 );
3237 }
3238 };
3239 },
3240 /**
3241 * Notified whenever a parent caller opens a connection. Use
3242 * to start emitting heavy publish events only when somebody
3243 * is listening.
3244 */
3245 onConnection: function ( cb ) {
3246 if ( typeof cb !== 'function' ) {
3247 return function () {};
3248 }
3249 _wpdConnectionListeners.push( cb );
3250 /* Replay current connections — late subscribers still
3251 * see who's already there. */
3252 var ids = Object.keys( _wpdConnections );
3253 for ( var i = 0; i < ids.length; i++ ) {
3254 try {
3255 cb( {
3256 id: _wpdConnections[ ids[ i ] ].id,
3257 topics: _wpdConnections[ ids[ i ] ].topics.slice()
3258 } );
3259 } catch ( _err ) { /* swallow */ }
3260 }
3261 return function () {
3262 var i = _wpdConnectionListeners.indexOf( cb );
3263 if ( i >= 0 ) {
3264 _wpdConnectionListeners.splice( i, 1 );
3265 }
3266 };
3267 },
3268 /**
3269 * Iframe-initiated connection request. See
3270 * `assets/js/iframe-bridge.js` — same shape, same protocol.
3271 */
3272 requestConnection: function ( opts ) {
3273 opts = opts || {};
3274 var topics = Array.isArray( opts.topics ) ? opts.topics.slice() : [];
3275 var requestId = 'wpdir-' + Math.random().toString( 36 ).slice( 2, 10 );
3276
3277 return new Promise( function ( resolve, reject ) {
3278 var settled = false;
3279 var timeoutMs = typeof opts.timeoutMs === 'number'
3280 ? opts.timeoutMs
3281 : 5000;
3282
3283 function settle( ok, value ) {
3284 if ( settled ) {
3285 return;
3286 }
3287 settled = true;
3288 window.removeEventListener( 'message', onAck );
3289 clearTimeout( timer );
3290 if ( ok ) {
3291 resolve( value );
3292 } else {
3293 reject( value );
3294 }
3295 }
3296
3297 function onAck( ev ) {
3298 if ( ev.origin !== _wpdParentOrigin ) {
3299 return;
3300 }
3301 var d = ev && ev.data;
3302 if (
3303 ! d ||
3304 typeof d !== 'object' ||
3305 d.type !== 'os-bridge-connection-ack' ||
3306 d.requestId !== requestId
3307 ) {
3308 return;
3309 }
3310 if ( d.accepted ) {
3311 var summary = {
3312 id: typeof d.connectionId === 'string' ? d.connectionId : '',
3313 topics: topics.slice()
3314 };
3315 if ( typeof opts.onOpen === 'function' ) {
3316 try { opts.onOpen( summary ); } catch ( _err ) { /* swallow */ }
3317 }
3318 settle( true, summary );
3319 } else {
3320 settle( false, new Error( d.reason || 'rejected' ) );
3321 }
3322 }
3323 window.addEventListener( 'message', onAck );
3324
3325 var timer = setTimeout( function () {
3326 settle( false, new Error( 'timeout' ) );
3327 }, timeoutMs );
3328
3329 try {
3330 window.parent.postMessage( {
3331 type: 'os-bridge-connection-request',
3332 requestId: requestId,
3333 topics: topics
3334 }, _wpdParentOrigin );
3335 } catch ( err ) {
3336 settle( false, err );
3337 }
3338 } );
3339 },
3340 /**
3341 * Window-chrome helpers. See `assets/js/iframe-bridge.js` —
3342 * same shape, same protocol. `setSlot` is HTML-only
3343 * (sandboxed via `textContent` on the parent side).
3344 */
3345 chrome: {
3346 setTheme: function ( tokens ) {
3347 try {
3348 window.parent.postMessage( {
3349 type: 'os-chrome-theme',
3350 tokens: tokens || {}
3351 }, _wpdParentOrigin );
3352 } catch ( _err ) { /* parent gone */ }
3353 },
3354 setControls: function ( config ) {
3355 try {
3356 window.parent.postMessage( {
3357 type: 'os-chrome-controls',
3358 config: config === undefined ? null : config
3359 }, _wpdParentOrigin );
3360 } catch ( _err ) { /* parent gone */ }
3361 },
3362 setSlot: function ( name, html ) {
3363 if ( typeof name !== 'string' || name === '' ) {
3364 return;
3365 }
3366 try {
3367 window.parent.postMessage( {
3368 type: 'os-chrome-slot',
3369 slot: name,
3370 html: typeof html === 'string' ? html : ''
3371 }, _wpdParentOrigin );
3372 } catch ( _err ) { /* parent gone */ }
3373 }
3374 },
3375 /**
3376 * The id of the window the parent shell opened to host this
3377 * iframe. Populated by the first connection handshake (the
3378 * parent's handshake carries `targetWindowId`). `null` until
3379 * then.
3380 */
3381 get windowId() {
3382 return _wpdWindowId;
3383 },
3384 /**
3385 * Resolve once `windowId` is populated by the first handshake.
3386 * Resolves immediately if already known. Never rejects — guard
3387 * with `isParentReachable()` first.
3388 */
3389 whenWindowId: function () {
3390 if ( _wpdWindowId !== null ) {
3391 return Promise.resolve( _wpdWindowId );
3392 }
3393 return new Promise( function ( resolve ) {
3394 _wpdWindowIdWaiters.push( resolve );
3395 } );
3396 },
3397 /**
3398 * Whether the parent frame is same-origin and reachable. All
3399 * bridge messages hard-filter on origin — a cross-origin
3400 * parent silently drops everything we post. Use this predicate
3401 * to fail fast instead of debugging vanishing messages.
3402 */
3403 isParentReachable: function () {
3404 if ( ! window.parent || window.parent === window ) {
3405 return false;
3406 }
3407 try {
3408 /* Cross-origin parents throw on `.location.origin`
3409 * access; same-origin parents return a string we can
3410 * compare to our own origin. */
3411 return window.parent.location.origin === _wpdParentOrigin;
3412 } catch ( _err ) {
3413 return false;
3414 }
3415 }
3416 };
3417
3418 if ( ! window.wp ) { window.wp = {}; }
3419 if ( ! window.wp.os ) { window.wp.os = {}; }
3420 window.wp.os.iframe = iframeApi;
3421
3422 /* Unified window-channel API. Mirror of the equivalent block
3423 * in `assets/js/iframe-bridge.js` — keep both in sync. The
3424 * parent shell posts `os-window-send` on
3425 * `Window.send( channel, payload )`; iframe-side handlers
3426 * register via `wp.os.on( channel, cb )`. Sending the
3427 * other way (`wp.os.send`) posts up to the parent where
3428 * `Window.on( channel, cb )` subscribers fire. */
3429 if ( typeof window.wp.os.send !== 'function' ) {
3430 window.wp.os.send = function ( channel, payload ) {
3431 if ( typeof channel !== 'string' || channel === '' ) {
3432 return;
3433 }
3434 try {
3435 window.parent.postMessage( {
3436 type: 'os-window-publish',
3437 channel: channel,
3438 payload: payload
3439 }, _wpdParentOrigin );
3440 } catch ( _err ) { /* parent gone */ }
3441 };
3442 }
3443 if ( typeof window.wp.os.on !== 'function' ) {
3444 window.wp.os.on = function ( channel, cb ) {
3445 if ( typeof channel !== 'string' || channel === '' || typeof cb !== 'function' ) {
3446 return function () {};
3447 }
3448 var bucket = _wpdChannelSubs[ channel ];
3449 if ( ! bucket ) {
3450 bucket = [];
3451 _wpdChannelSubs[ channel ] = bucket;
3452 }
3453 bucket.push( cb );
3454 return function () {
3455 var i = bucket.indexOf( cb );
3456 if ( i >= 0 ) {
3457 bucket.splice( i, 1 );
3458 }
3459 };
3460 };
3461 }
3462
3463 /* -----------------------------------------------------------------
3464 * Stale-nonce recovery after a session-expiry re-login.
3465 *
3466 * When the user's session expires while a chromeless window is
3467 * open, this iframe does NOT show core's `wp-auth-check` login
3468 * modal — `openstation_chromeless_suppress_auth_check()` keeps
3469 * the modal assets out of chromeless requests so the parent
3470 * shell owns the single prompt for the whole desktop. Detection
3471 * still works without the modal JS: core attaches the
3472 * `wp-auth-check` boolean to every heartbeat response
3473 * server-side, and this iframe's own heartbeat keeps ticking.
3474 *
3475 * After re-auth the auth cookie is fresh — but every per-page
3476 * nonce cached in JS globals (`_wpUpdatesSettings.ajax_nonce`,
3477 * `commonL10n.nonce`, Gutenberg's `wpApiSettings.nonce`, etc.)
3478 * was minted under the OLD session and is now rejected by
3479 * `check_ajax_referer`. WP reports that as "Cookie check
3480 * failed" on the next plugin Install / Activate / Update click,
3481 * which is misleading: the cookie is fine; the nonce is stale.
3482 *
3483 * Fix: watch jQuery's `heartbeat-tick`. If we ever see
3484 * `wp-auth-check: false` and then later see the same field flip
3485 * back to `true`, the user re-authed mid-session and every
3486 * cached nonce in this iframe is stale — reload so they
3487 * regenerate from the fresh session. The parent is nudged
3488 * first (`os-reauth-detected`) so its own recovery
3489 * (`src/auth-recovery/index.ts`: in-place nonce refresh + a
3490 * reload sweep over sibling iframes that haven't ticked yet)
3491 * starts immediately instead of waiting for the parent's
3492 * heartbeat schedule.
3493 *
3494 * If jQuery never loads on this page (rare — most admin screens
3495 * pull it for heartbeat already), this block is a no-op.
3496 * ----------------------------------------------------------------- */
3497 ( function _wpdInstallAuthCheckRecovery() {
3498 var attached = false;
3499 var sawLoggedOut = false;
3500 function attach() {
3501 if ( attached || ! window.jQuery ) {
3502 return;
3503 }
3504 attached = true;
3505 window.jQuery( document ).on( 'heartbeat-tick.osAuthRecover', function ( ev, data ) {
3506 if ( ! data || typeof data !== 'object' || ! ( 'wp-auth-check' in data ) ) {
3507 return;
3508 }
3509 if ( data[ 'wp-auth-check' ] === false ) {
3510 sawLoggedOut = true;
3511 return;
3512 }
3513 if ( sawLoggedOut && data[ 'wp-auth-check' ] === true ) {
3514 sawLoggedOut = false;
3515 // Tell the parent shell BEFORE we reload so it
3516 // doesn't have to wait for its own heartbeat
3517 // tick (up to 60s on an idle shell) to discover
3518 // the cookie is fresh. Parent runs its full
3519 // recovery path on receipt — overlay teardown,
3520 // iframe reload sweep, then a hard reload.
3521 try {
3522 if ( window.parent && window.parent !== window ) {
3523 window.parent.postMessage(
3524 { type: 'os-reauth-detected' },
3525 window.location.origin
3526 );
3527 }
3528 } catch ( _err ) { /* parent gone */ }
3529 try { window.location.reload(); } catch ( _err ) { /* swallow */ }
3530 }
3531 } );
3532 }
3533 attach();
3534 if ( document.readyState === 'loading' ) {
3535 document.addEventListener( 'DOMContentLoaded', attach, { once: true } );
3536 }
3537 window.addEventListener( 'load', attach, { once: true } );
3538 } )();
3539
3540 /* -----------------------------------------------------------------
3541 * Shiny-update watcher (GH#296).
3542 *
3543 * Core's updates.js applies plugin/theme updates and deletes over
3544 * AJAX — no navigation, so the load-time payload emit above never
3545 * re-fires and the shell's update notifiers (admin-bar circle-arrows
3546 * count, dock Plugins badge) keep showing the pre-update numbers
3547 * until a hard refresh. Watch the jQuery events updates.js triggers
3548 * on `document` after each job and nudge the shell to spend one
3549 * `refreshMenu()` probe, whose payload carries fresh counts.
3550 *
3551 * Error events are included deliberately: `wp_ajax_update_plugin`
3552 * calls `wp_update_plugins()` up front, which can mutate the
3553 * update transient even when the upgrade itself fails.
3554 *
3555 * When updates.js is processing a queue (bulk-selected shiny
3556 * updates), per-job events fire while later jobs are still
3557 * pending — skip those and let the final job's event send the one
3558 * nudge. The shell debounces on its side too, so this is purely
3559 * an optimization, not a correctness gate.
3560 *
3561 * If jQuery never loads on this page this block is a no-op — and
3562 * so is updates.js, which requires it.
3563 * ----------------------------------------------------------------- */
3564 ( function _wpdInstallShinyUpdateWatcher() {
3565 var attached = false;
3566 function notify() {
3567 try {
3568 var queue = window.wp && window.wp.updates && window.wp.updates.queue;
3569 if ( queue && queue.length > 0 ) {
3570 return;
3571 }
3572 } catch ( _err ) { /* queue introspection is best-effort */ }
3573 try {
3574 var shell = window.top || window.parent;
3575 if ( shell && shell !== window ) {
3576 shell.postMessage(
3577 { type: 'os-updates-changed' },
3578 window.location.origin
3579 );
3580 }
3581 } catch ( _err ) { /* shell gone or cross-origin */ }
3582 }
3583 function notifyPluginInstall() {
3584 // `wp-plugin-install-success` fires after an AJAX install on
3585 // plugin-install.php with no page navigation. The PHP
3586 // `upgrader_process_complete` hook records the change correctly,
3587 // but `openstation_content_changes_emit_footer` only runs on
3588 // chromeless page requests — admin-ajax.php is not in the
3589 // chromeless allowlist, so there's no in-band emit from that
3590 // request. The Heartbeat buffer will eventually deliver it, but
3591 // posting directly here lets the Installed tab refresh
3592 // immediately. The later Heartbeat tick will produce a second
3593 // broadcast; consumers handle no-op refreshes gracefully.
3594 try {
3595 var shell = window.top || window.parent;
3596 if ( shell && shell !== window ) {
3597 shell.postMessage(
3598 {
3599 type: 'os-broadcast',
3600 topic: 'os.plugin.changed',
3601 payload: { source: 'chromeless-bridge', action: 'install' }
3602 },
3603 window.location.origin
3604 );
3605 }
3606 } catch ( _err ) { /* shell gone or cross-origin */ }
3607 }
3608 function attach() {
3609 if ( attached || ! window.jQuery ) {
3610 return;
3611 }
3612 attached = true;
3613 window.jQuery( document ).on(
3614 [
3615 'wp-plugin-update-success.osUpdates',
3616 'wp-plugin-update-error.osUpdates',
3617 'wp-plugin-delete-success.osUpdates',
3618 'wp-theme-update-success.osUpdates',
3619 'wp-theme-update-error.osUpdates',
3620 'wp-theme-delete-success.osUpdates'
3621 ].join( ' ' ),
3622 notify
3623 );
3624 window.jQuery( document ).on( 'wp-plugin-install-success.osUpdates', notifyPluginInstall );
3625 }
3626 attach();
3627 if ( document.readyState === 'loading' ) {
3628 document.addEventListener( 'DOMContentLoaded', attach, { once: true } );
3629 }
3630 window.addEventListener( 'load', attach, { once: true } );
3631 } )();
3632
3633 /*
3634 * Bridge-ready signal. Every listener installed by this script
3635 * is now wired; let the parent shell know so it can fire
3636 * `HOOKS.IFRAME_READY` and re-arm any connection handshakes
3637 * (`src/connection/index.ts#onIframeReady`) that arrived before
3638 * we were listening. Without this, every consumer of
3639 * `HOOKS.IFRAME_READY` (devtools replay, connection rearm)
3640 * stays silent for the lifetime of the iframe — documented
3641 * surface that never actually fires.
3642 *
3643 * Posted to the parent's own origin only. Wrapped in try/catch
3644 * because cross-origin parents (top-level admin opened outside
3645 * the shell) would throw on the postMessage and we don't want a
3646 * single failed dispatch to wedge anything else above.
3647 */
3648 try {
3649 if ( window.parent && window.parent !== window ) {
3650 window.parent.postMessage(
3651 { type: 'os-ready' },
3652 window.location.origin
3653 );
3654 }
3655 } catch ( _err ) { /* parent gone or cross-origin */ }
3656 } )();
3657 JS;
3658
3659 // On pages that don't carry a full payload, ship the lightweight
3660 // menu signature so the shell can detect an off-allowlist menu
3661 // change (e.g. a CPT registered via a settings tool) and refresh
3662 // only then. The full payload already embeds its own `menuSig`, so
3663 // there's no point recomputing it when one is being sent. GH#325.
3664 $menu_sig_json = 'null';
3665 if ( 'null' === $menu_payload_json ) {
3666 $menu_sig = openstation_menu_signature();
3667 if ( '' !== $menu_sig ) {
3668 $encoded_sig = wp_json_encode( $menu_sig );
3669 if ( false !== $encoded_sig ) {
3670 $menu_sig_json = $encoded_sig;
3671 }
3672 }
3673 }
3674
3675 // Declarative soft-reload rules for list screens that are NOT a
3676 // standard `edit.php?post_type=<type>` / `upload.php` /
3677 // `edit-comments.php` page (those are matched generically in the
3678 // bridge script). Rule shape:
3679 // - `topic` — the `os.<type>.changed` topic.
3680 // - `path` — wp-admin filename (`admin.php`).
3681 // - `query` — required query params (exact match).
3682 // - `queryAbsent` — params that must NOT be present.
3683 //
3684 // The default rule covers WooCommerce's HPOS orders list.
3685 // `queryAbsent: [ 'action' ]` is load-bearing: with `&action=edit`
3686 // the same path is the single-order EDITOR, which must keep the
3687 // single-edit exclusion (a soft reload would destroy unsaved
3688 // order state). Shipped unconditionally — when WooCommerce is
3689 // absent the URL never renders and the rule is inert.
3690 $soft_reload_rules = array(
3691 array(
3692 'topic' => 'os.shop_order.changed',
3693 'path' => 'admin.php',
3694 'query' => array( 'page' => 'wc-orders' ),
3695 'queryAbsent' => array( 'action' ),
3696 ),
3697 );
3698
3699 /**
3700 * Filters the declarative soft-reload rules injected into every
3701 * chromeless iframe.
3702 *
3703 * Lets a plugin whose list screen lives on a custom admin URL
3704 * participate in cross-window refresh: pair a rule here with
3705 * `openstation_content_changes_record()` calls (or your own
3706 * `os.<type>.changed` broadcasts) on the publish side.
3707 *
3708 * @param array $soft_reload_rules Rule arrays with keys `topic`,
3709 * `path`, `query`, `queryAbsent`.
3710 */
3711 $soft_reload_rules = (array) apply_filters( 'openstation_soft_reload_rules', $soft_reload_rules );
3712 $soft_reload_json = wp_json_encode( array_values( $soft_reload_rules ) );
3713 if ( ! $soft_reload_json ) {
3714 $soft_reload_json = '[]';
3715 }
3716
3717 // Substitute the server-built menu payload into the bridge
3718 // script. `wp_json_encode` guarantees safe JSON output — no need
3719 // for an additional escape pass. When the page isn't on our
3720 // menu-altering allowlist the placeholder resolves to `null` and
3721 // the bridge skips the postMessage.
3722 $js = str_replace( '/*__OPENSTATION_MENU_PAYLOAD__*/', $menu_payload_json, $js );
3723 $js = str_replace( '/*__OPENSTATION_MENU_SIG__*/', $menu_sig_json, $js );
3724 $js = str_replace( '/*__OPENSTATION_CONTENT_IDENTITY__*/', $content_identity_json, $js );
3725 $js = str_replace( '/*__OPENSTATION_SOFT_RELOAD_EXTRAS__*/', $soft_reload_json, $js );
3726
3727 wp_print_inline_script_tag( $js );
3728 }
3729 add_action( 'admin_footer', 'openstation_chromeless_bridge_script' );
3730