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

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