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

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