PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.1
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.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.1.1, at includes/render/chromeless-bridge.php

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