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

2,979 lines 106.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — Chromeless iframe bridge.
4 *
5 * Two cooperative pieces emitted into chromeless admin pages:
6 *
7 * - `desktop_mode_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 * - `desktop_mode_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 Desktop_Mode
25 * @since 0.8.1
26 */
27
28 defined( 'ABSPATH' ) || exit;
29
30
31 /**
32 * Neutralizes hardcoded admin-bar offsets on positioned elements
33 * inside chromeless iframes.
34 *
35 * Many plugins compile their CSS with the admin-bar height baked in
36 * as a literal pixel value rather than referencing
37 * `var(--wp-admin--admin-bar--height)`. WooCommerce's
38 * `.woocommerce-layout__header` is the canonical case — it ships as
39 * `top: 32px` (or `46px` on small screens) because the SCSS source
40 * uses build-time interpolation (`#{$header-height + $adminbar-height-mobile}`).
41 * A CSS-variable rebind cannot reach these rules because the rules
42 * never read the variable.
43 *
44 * The only generic mitigation is a runtime DOM pass:
45 *
46 * 1. Walk every positioned element (`fixed | sticky | absolute`).
47 * 2. Compare its computed `top` against the set of values that
48 * reserve admin-bar height (defaults: `32px`, `46px`).
49 * 3. If it matches, override `top` to `0` inline with `!important`.
50 *
51 * The match is exact-pixel — we deliberately don't catch e.g.
52 * `top: 33px` (which is almost certainly intentional and unrelated
53 * to admin-bar geometry). False positives are possible but
54 * unlikely; a plugin would have to use `top: 32px` for a reason
55 * unrelated to the admin bar AND need that exact value to remain
56 * inside chromeless. We've never seen one in the wild, and if a
57 * site hits it, the filter below lets them narrow the scan.
58 *
59 * Scoped via the `desktop-mode-chromeless` body class. Runs ONE
60 * full walk at DOMContentLoaded, then watches for late additions
61 * with a `MutationObserver` so React-mounted components are
62 * corrected as they appear instead of via a second full-DOM walk
63 * at `load`. The observer only inspects added nodes, not the
64 * whole document, which is roughly two orders of magnitude
65 * cheaper than the old double-walk on a busy Gutenberg or
66 * WooCommerce admin page (~2,000+ `getComputedStyle()` calls
67 * collapsed into a one-time initial walk plus per-addition
68 * checks).
69 *
70 * Fallback for very old browsers without `MutationObserver`:
71 * keep the second walk at `load`. The current minimum (IE 11+)
72 * already ships MO, so the fallback only fires on extreme
73 * outliers — but it's free insurance.
74 *
75 * @since 0.6.1
76 */
77 function desktop_mode_chromeless_offset_neutralizer_script() {
78 if ( ! desktop_mode_is_chromeless_request() ) {
79 return;
80 }
81
82 /**
83 * Filters the set of `top` pixel values that mark a positioned
84 * element as an admin-bar offset clone.
85 *
86 * Defaults match the two admin-bar heights Core ships: `32px`
87 * for desktop, `46px` for the mobile breakpoint. Sites that
88 * customize the admin bar height (some accessibility themes
89 * raise it to 50px) can extend the list.
90 *
91 * @since 0.6.1
92 *
93 * @param string[] $values Default `[ '32px', '46px' ]`.
94 */
95 $top_values = apply_filters(
96 'desktop_mode_chromeless_admin_bar_top_values',
97 array( '32px', '46px' )
98 );
99
100 $config = wp_json_encode(
101 array(
102 'tops' => array_values( array_filter( array_map( 'strval', (array) $top_values ) ) ),
103 )
104 );
105 if ( false === $config ) {
106 return;
107 }
108
109 // Build the inline JS as a concatenated single-quoted string —
110 // Plugin Check disallows heredoc syntax (PluginCheck.CodeAnalysis.
111 // Heredoc.NotAllowed), so the source is uglier than the original
112 // `<<<JS … JS;` block but functionally identical. The trailing
113 // `$config` JSON is appended at the end so the whole body is a
114 // closure receiving a `{tops: [...]}` argument.
115 $js = '(function(C){';
116 $js .= 'var TOPS={};';
117 $js .= 'for(var t=0;t<C.tops.length;t++){TOPS[C.tops[t]]=1;}';
118 $js .= 'function fixOne(el){';
119 $js .= 'if(!el||el.nodeType!==1)return;';
120 $js .= 'var cs;';
121 $js .= 'try{cs=getComputedStyle(el);}catch(_e){return;}';
122 $js .= "if(cs.position==='static')return;";
123 $js .= "if(TOPS[cs.top]){el.style.setProperty('top','0px','important');}";
124 $js .= '}';
125 $js .= 'function walkSubtree(root){';
126 $js .= 'if(!root)return;';
127 $js .= 'if(root.nodeType===1){fixOne(root);}';
128 $js .= "var els=root.querySelectorAll?root.querySelectorAll('*'):[];";
129 $js .= 'for(var i=0;i<els.length;i++){fixOne(els[i]);}';
130 $js .= '}';
131 $js .= 'var started=false;';
132 $js .= 'function start(){';
133 $js .= 'if(started)return;';
134 $js .= "if(!document.body||!document.body.classList.contains('desktop-mode-chromeless'))return;";
135 $js .= 'started=true;';
136 $js .= 'var MO=window.MutationObserver;';
137 $js .= 'if(MO){';
138 $js .= 'var observer=new MO(function(records){';
139 $js .= 'for(var r=0;r<records.length;r++){';
140 $js .= 'var rec=records[r];';
141 $js .= "if(rec.type!=='childList')continue;";
142 $js .= 'var added=rec.addedNodes;';
143 $js .= 'for(var n=0;n<added.length;n++){walkSubtree(added[n]);}';
144 $js .= '}';
145 $js .= '});';
146 $js .= 'observer.observe(document.body,{childList:true,subtree:true});';
147 $js .= '}';
148 $js .= 'walkSubtree(document.body);';
149 // Defense in depth — pre-MutationObserver browsers fall back to the
150 // original double-walk so React-mounted components added between
151 // DOMContentLoaded and load still get neutralized.
152 $js .= 'if(!MO){';
153 $js .= "window.addEventListener('load',function(){walkSubtree(document.body);},{once:true});";
154 $js .= '}';
155 $js .= '}';
156 $js .= "if(document.readyState==='loading'){";
157 $js .= "document.addEventListener('DOMContentLoaded',start,{once:true});";
158 $js .= '}else{';
159 $js .= 'start();';
160 $js .= '}';
161 $js .= '})(' . $config . ');';
162
163 wp_print_inline_script_tag( $js );
164 }
165 add_action( 'admin_head', 'desktop_mode_chromeless_offset_neutralizer_script', 1 );
166
167 /**
168 * Short-circuit `admin.php?desktop_mode_menu_refresh=1` requests with
169 * a tiny inline-script response that postMessages the current menu
170 * payload to the parent shell.
171 *
172 * The full chromeless bridge is hooked on `admin_footer`, which Core
173 * only fires from `admin-header.php` / `admin-footer.php`. Plain
174 * `admin.php` without `?page=` (or one of the other dispatch paths
175 * in admin.php) never includes the footer — the file just runs the
176 * `load-{$pagenow}` hook in the `else` branch and exits. The full
177 * bridge therefore never emits its payload, and the parent's
178 * `wp.desktop.refreshMenu()` waits out its 8-second timeout for a
179 * message that's never coming. That's the source of "deactivating a
180 * plugin leaves its dock icons behind" — the hidden probe iframe
181 * the shell spawns to harvest the post-mutation menu lands on a
182 * page that doesn't fire admin_footer.
183 *
184 * Hooking here on `admin_init @ 99` runs AFTER `wp-admin/menu.php`
185 * has loaded (which fires `admin_menu` and populates `$menu`) but
186 * BEFORE admin.php's per-page dispatch. We can emit the payload
187 * straight away and short-circuit the rest of admin.php so the probe
188 * resolves in milliseconds instead of timing out.
189 *
190 * No admin-header / admin-footer means no `#adminmenu` DOM, so the
191 * full bridge's CSS-icon harvest doesn't run here. That's an
192 * acceptable trade-off: items whose icons live in `$menu[$i][6]`
193 * (the vast majority) still ship correctly; items that rely on a
194 * CSS `::before` on `#adminmenu .menu-icon-<slug>` fall back to the
195 * default gear icon on a live refresh until the next full page load
196 * — strictly better than today's "dock doesn't update at all."
197 *
198 * @since 0.8.2
199 */
200 function desktop_mode_emit_menu_refresh_probe() {
201 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only payload harvest; capability-gated by chromeless gate below.
202 if ( empty( $_GET['desktop_mode_menu_refresh'] ) ) {
203 return;
204 }
205 if ( ! desktop_mode_is_chromeless_request() ) {
206 return;
207 }
208 // Only short-circuit the bare `admin.php` probe — for any real
209 // admin page (plugins.php, edit.php, etc.) we still want the full
210 // admin-footer-hosted bridge to fire so the icon harvest runs.
211 $pagenow = isset( $GLOBALS['pagenow'] ) ? (string) $GLOBALS['pagenow'] : '';
212 if ( 'admin.php' !== $pagenow ) {
213 return;
214 }
215
216 $payload = desktop_mode_build_menu_payload();
217 $encoded = wp_json_encode( $payload );
218 if ( false === $encoded ) {
219 return;
220 }
221
222 nocache_headers();
223 header( 'Content-Type: text/html; charset=utf-8' );
224
225 // Mirror the full bridge's message shape so the same shell-side
226 // listener consumes both.
227 echo '<!doctype html><html><head><meta charset="utf-8"><title></title></head><body>';
228 echo '<script>';
229 echo '(function(){try{if(window.parent&&window.parent!==window){window.parent.postMessage({type:"desktop-mode-plugins-changed",payload:';
230 echo $encoded; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode produces JSON-safe output.
231 echo '},window.location.origin);}}catch(e){}})();';
232 echo '</script>';
233 echo '</body></html>';
234 exit;
235 }
236 add_action( 'admin_init', 'desktop_mode_emit_menu_refresh_probe', 99 );
237
238 /**
239 * Outputs the chromeless screen-meta bridge script.
240 *
241 * Detects Screen Options / Help panels in the iframed page and relays
242 * their availability + open/closed state to the parent desktop shell
243 * via postMessage. The parent shell uses this to render matching
244 * buttons in the window title bar.
245 *
246 * @since 0.1.0
247 */
248 function desktop_mode_chromeless_bridge_script() {
249 if ( ! desktop_mode_is_chromeless_request() ) {
250 return;
251 }
252
253 /**
254 * Fires after chromeless content in desktop mode.
255 *
256 * @since 0.1.0
257 *
258 * @param string $hook_suffix The current admin page hook suffix.
259 */
260 do_action( 'desktop_mode_chromeless_after', isset( $GLOBALS['hook_suffix'] ) ? $GLOBALS['hook_suffix'] : '' );
261
262 // Menu payload — built from the LIVE $menu / $submenu globals
263 // populated by real admin-context bootstrapping. We capture it here
264 // rather than making the parent refetch via REST because many
265 // plugins evaluate `is_admin()` at plugin-file-load time and only
266 // register their `admin_menu` hook when it returns true; in a REST
267 // context `WP_ADMIN` isn't defined at load, so those plugins never
268 // hook in and their menu entries are missing from any endpoint we
269 // could expose. Here we're INSIDE an admin request (plugins.php,
270 // plugin-install.php, update.php, themes.php) where every plugin's
271 // menu registered normally, so `$menu` carries the authoritative
272 // post-activation state.
273 //
274 // Narrowed to the set of pages whose completion commonly mutates
275 // the admin menu (activation / deactivation / install / theme
276 // switch), plus the explicit `desktop_mode_menu_refresh=1` signal
277 // the shell sets when `wp.desktop.refreshMenu()` spawns a hidden
278 // iframe to harvest a fresh payload from real admin context.
279 // Navigating to edit.php or similar doesn't change the menu so we
280 // don't bother sending a payload otherwise — the debounce +
281 // idempotent replaceItems on the parent side would still make it
282 // safe, just wasteful.
283 $menu_payload_json = 'null';
284 $pagenow = isset( $GLOBALS['pagenow'] ) ? (string) $GLOBALS['pagenow'] : '';
285 $is_refresh_probe = ! empty( $_GET['desktop_mode_menu_refresh'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only payload harvest, capability-gated by the host admin page.
286 if (
287 $is_refresh_probe
288 || in_array(
289 $pagenow,
290 array( 'plugins.php', 'plugin-install.php', 'update.php', 'themes.php' ),
291 true
292 )
293 ) {
294 $encoded = wp_json_encode( desktop_mode_build_menu_payload() );
295 if ( false !== $encoded ) {
296 $menu_payload_json = $encoded;
297 }
298 }
299
300 // Content identity — which object this admin page shows ("comment 45
301 // of post 123"). Built here, in real admin context, because the URL
302 // alone can't resolve relations like comment → parent post. Always
303 // emitted (including `null`) so navigating an iframe from an
304 // identified page to an unidentified one clears the stale identity
305 // in the parent's relations engine.
306 $content_identity_json = wp_json_encode( desktop_mode_build_content_identity() );
307 if ( false === $content_identity_json ) {
308 $content_identity_json = 'null';
309 }
310
311 // Emit via wp_print_inline_script_tag so CSP nonces and `<script>`
312 // attribute hygiene go through Core rather than being hand-rolled.
313 $js = <<<'JS'
314 //# sourceURL=desktop-mode-chromeless-bridge.js
315 ( function() {
316 // Escape hatch: a chromeless page is only meant to live inside a
317 // desktop-mode window iframe. If the top window IS this page, the
318 // user ended up here directly — either bookmarked it, followed a
319 // stale link, or got stranded by a bad portal redirect. Without
320 // an admin bar there's no toggle to turn desktop mode off, so
321 // strip the chromeless flag and reload as classic admin. That
322 // puts the admin bar back and lets the user decide what to do.
323 if ( ! window.parent || window.parent === window ) {
324 try {
325 var here = new URL( window.location.href );
326 if ( here.searchParams.has( 'desktop_mode_chromeless' ) ) {
327 here.searchParams.delete( 'desktop_mode_chromeless' );
328 here.searchParams.delete( 'desktop_mode_portal' );
329 window.location.replace( here.toString() );
330 }
331 } catch ( err ) {
332 /* URL parse failure — let the broken state stand rather than
333 * navigate somewhere worse. */
334 }
335 return;
336 }
337
338 /*
339 * Content-identity announcement. The server resolved which object
340 * this page shows (post / comment / attachment, plus the root post
341 * a child belongs to) while it still had real admin context; hand
342 * it to the parent's relations engine. Deliberately posted even
343 * when the identity is null — a full-page navigation away from an
344 * identified screen must CLEAR the stale identity, and every
345 * navigation re-runs admin_footer, so this doubles as the
346 * re-announce-on-navigate path.
347 *
348 * Posted FIRST, right after the top-frame escape hatch, because it
349 * depends on nothing else in this script: a page-specific runtime
350 * failure in any of the feature blocks below (screen-meta harvest,
351 * command scan, link interceptor, …) must not cost the shell its
352 * window relations. The `desktop-mode-ready` signal intentionally
353 * stays LAST — it means "every listener below is wired".
354 */
355 try {
356 window.parent.postMessage(
357 {
358 type: 'desktop-mode-content-identity',
359 identity: /*__DESKTOP_MODE_CONTENT_IDENTITY__*/
360 },
361 window.location.origin
362 );
363 } catch ( _err ) { /* parent gone or cross-origin */ }
364
365 /*
366 * Observability — iframe error + network capture.
367 *
368 * Everything admin-interesting (REST failures from Gutenberg,
369 * admin-ajax 500s, plugin console warnings) fires INSIDE the
370 * iframe whose parent is the desktop shell. Without relaying
371 * those events to the shell, monitor / debug widgets would only
372 * ever see the shell's own errors — the smallest, least-
373 * interesting surface in the whole admin.
374 *
375 * Two listeners and two wrappers land here:
376 *
377 * - `error` + `unhandledrejection` on window → postMessage
378 * `desktop-mode-iframe-error`. Parent dispatches `HOOKS.
379 * IFRAME_ERROR`.
380 * - `fetch` + `XMLHttpRequest` are wrapped so every completed
381 * request (including failures) posts
382 * `desktop-mode-iframe-network` with `{ method, url, status,
383 * duration, failed }`. Parent dispatches `HOOKS.
384 * IFRAME_NETWORK_COMPLETED`.
385 *
386 * Privacy: request / response bodies are NEVER captured — only
387 * method, URL, status, duration. Monitor widgets that want the
388 * full payload must ship their own deeper wrapper (at which
389 * point they own the consent conversation).
390 */
391 try {
392 window.addEventListener( 'error', function ( e ) {
393 try {
394 window.parent.postMessage( {
395 type: 'desktop-mode-iframe-error',
396 kind: 'error',
397 message: e && e.message ? String( e.message ) : '',
398 filename: e && e.filename ? String( e.filename ) : null,
399 lineno: e && typeof e.lineno === 'number' ? e.lineno : null,
400 colno: e && typeof e.colno === 'number' ? e.colno : null,
401 stack: e && e.error && e.error.stack ? String( e.error.stack ) : null
402 }, window.location.origin );
403 } catch ( _err ) { /* swallow: don't let the relay compound the error */ }
404 } );
405
406 window.addEventListener( 'unhandledrejection', function ( e ) {
407 try {
408 var reason = e && 'reason' in e ? e.reason : null;
409 var message = '';
410 var stack = null;
411 if ( reason instanceof Error ) {
412 message = reason.message;
413 stack = reason.stack || null;
414 } else if ( reason !== null && reason !== undefined ) {
415 try { message = String( reason ); } catch ( _s ) { message = '[unstringifiable]'; }
416 }
417 window.parent.postMessage( {
418 type: 'desktop-mode-iframe-error',
419 kind: 'unhandledrejection',
420 message: message,
421 filename: null,
422 lineno: null,
423 colno: null,
424 stack: stack
425 }, window.location.origin );
426 } catch ( _err ) { /* swallow */ }
427 } );
428
429 // Devtools instrumentation slot — populated by
430 // `desktop-mode-instrument-set` messages from the parent shell.
431 // Mutable: parent overwrites the whole object on every change
432 // (header add/remove, observe toggle).
433 //
434 // Headers: { name: 'value' } — already pre-merged by the parent
435 // (RFC 7230 §3.2.2 join applied there).
436 // Observe: when true, network reports include request +
437 // response headers; otherwise only the privacy-conscious
438 // summary travels parent-bound.
439 window.__wpdInstrument = window.__wpdInstrument || { headers: {}, observe: false };
440 try {
441 window.addEventListener( 'message', function ( ev ) {
442 if ( ev.origin !== window.location.origin || ev.source !== window.parent ) {
443 return;
444 }
445 var d = ev && ev.data;
446 if ( ! d || typeof d !== 'object' || d.type !== 'desktop-mode-instrument-set' ) {
447 return;
448 }
449 window.__wpdInstrument = {
450 headers: d.headers && typeof d.headers === 'object' ? d.headers : {},
451 observe: !! d.observe
452 };
453 } );
454 } catch ( _err ) { /* swallow — instrumentation is best-effort */ }
455
456 var wpdReportNetwork = function ( method, url, status, duration, failed, extra ) {
457 try {
458 var msg = {
459 type: 'desktop-mode-iframe-network',
460 method: String( method || 'GET' ).toUpperCase(),
461 url: String( url || '' ),
462 status: typeof status === 'number' ? status : 0,
463 duration: typeof duration === 'number' ? duration : 0,
464 failed: !! failed
465 };
466 if ( extra && window.__wpdInstrument && window.__wpdInstrument.observe ) {
467 if ( extra.requestHeaders ) {
468 msg.requestHeaders = extra.requestHeaders;
469 }
470 if ( extra.responseHeaders ) {
471 msg.responseHeaders = extra.responseHeaders;
472 }
473 }
474 window.parent.postMessage( msg, window.location.origin );
475 } catch ( _err ) { /* swallow */ }
476 };
477
478 // Helper — when an admin-side request returns 401/403 the
479 // session is most likely toast. Don't wait up to 60s for the
480 // next heartbeat tick to surface core's auth-check modal —
481 // force an immediate tick. `wp.heartbeat.connectNow()` is
482 // safe to call repeatedly; we still debounce to avoid storms
483 // when many requests fail at once. Same-origin gate keeps us
484 // out of third-party 403s. The URL gate avoids looping on
485 // heartbeat itself (heartbeat shouldn't 403 — but if it does
486 // the recursive connectNow would not help anyway).
487 var wpdAuthCheckCooldownUntil = 0;
488 var wpdMaybeForceAuthCheck = function ( status, url ) {
489 if ( status !== 401 && status !== 403 ) {
490 return;
491 }
492 var urlStr = String( url || '' );
493 if ( ! urlStr ) {
494 return;
495 }
496 // Cross-origin URLs aren't ours to interpret.
497 try {
498 var resolved = new URL( urlStr, window.location.href );
499 if ( resolved.origin !== window.location.origin ) {
500 return;
501 }
502 // Skip heartbeat to avoid recursion. Skip wp-login
503 // because the login iframe itself returns 4xx during
504 // the auth handshake and we don't want to retrigger.
505 if (
506 resolved.pathname.indexOf( '/wp-admin/admin-ajax.php' ) !== -1
507 && /(?:^|&|\?)action=heartbeat(?:&|$)/.test( resolved.search )
508 ) {
509 return;
510 }
511 if ( resolved.pathname.indexOf( '/wp-login.php' ) !== -1 ) {
512 return;
513 }
514 } catch ( _err ) {
515 return;
516 }
517 var now = Date.now();
518 if ( now < wpdAuthCheckCooldownUntil ) {
519 return;
520 }
521 wpdAuthCheckCooldownUntil = now + 5000;
522 try {
523 if (
524 window.wp
525 && window.wp.heartbeat
526 && typeof window.wp.heartbeat.connectNow === 'function'
527 ) {
528 window.wp.heartbeat.connectNow();
529 }
530 } catch ( _err ) { /* swallow */ }
531 };
532
533 // Helper — convert an arbitrary `init.headers` shape into a
534 // plain `{ name: value }` map so the instrument layer can
535 // merge contributed headers without caring whether the caller
536 // passed a Headers, an array of pairs, or a plain object.
537 var wpdHeadersToObject = function ( h ) {
538 var out = {};
539 if ( ! h ) {
540 return out;
541 }
542 if ( typeof Headers !== 'undefined' && h instanceof Headers ) {
543 try {
544 h.forEach( function ( v, k ) { out[ k ] = v; } );
545 } catch ( _e ) { /* swallow */ }
546 return out;
547 }
548 if ( Array.isArray( h ) ) {
549 for ( var i = 0; i < h.length; i++ ) {
550 if ( h[ i ] && h[ i ].length >= 2 ) {
551 out[ h[ i ][ 0 ] ] = h[ i ][ 1 ];
552 }
553 }
554 return out;
555 }
556 if ( typeof h === 'object' ) {
557 for ( var k in h ) {
558 if ( Object.prototype.hasOwnProperty.call( h, k ) ) {
559 out[ k ] = h[ k ];
560 }
561 }
562 }
563 return out;
564 };
565
566 // Helper — snapshot the contributed-header set at request time.
567 // Header values can theoretically come and go between requests
568 // (parent ref-counts contributions) so we read fresh on every
569 // call rather than caching at wrap time.
570 var wpdContributedHeaders = function () {
571 var inst = window.__wpdInstrument || {};
572 var headers = inst.headers || {};
573 var out = {};
574 for ( var k in headers ) {
575 if ( Object.prototype.hasOwnProperty.call( headers, k ) && typeof headers[ k ] === 'string' ) {
576 out[ k ] = headers[ k ];
577 }
578 }
579 return out;
580 };
581
582 // Wrap fetch. Called AFTER `admin_footer` runs — plugin code
583 // using fetch during synchronous page boot (rare in wp-admin)
584 // bypasses this, but lazy calls (the common case) are captured.
585 //
586 // Two layers of behavior:
587 //
588 // - Always: timing + status reporting (the original
589 // observability contract).
590 // - When `__wpdInstrument.headers` is non-empty: merge those
591 // headers into the request before dispatch so devtools can
592 // tag every outgoing call without each plugin reinventing
593 // a fetch wrapper.
594 // - When `__wpdInstrument.observe`: also relay request +
595 // response headers in the parent-bound network message.
596 if ( typeof window.fetch === 'function' ) {
597 var wpdOrigFetch = window.fetch;
598 window.fetch = function ( input, init ) {
599 var start = ( typeof performance !== 'undefined' && performance.now )
600 ? performance.now()
601 : Date.now();
602 var method = 'GET';
603 var url = '';
604 if ( typeof input === 'string' ) {
605 url = input;
606 if ( init && typeof init.method === 'string' ) {
607 method = init.method;
608 }
609 } else if ( input && typeof input === 'object' ) {
610 url = input.url || '';
611 method = ( input.method || ( init && init.method ) || 'GET' );
612 }
613
614 // Header contribution + capture. Build a single
615 // `Headers` instance so contributed values overwrite /
616 // stack predictably regardless of the caller's input
617 // shape, then re-attach to a cloned init.
618 var contributed = wpdContributedHeaders();
619 var observe = window.__wpdInstrument && window.__wpdInstrument.observe;
620 var requestHeaders = null;
621 var hasContributed = false;
622 for ( var ck in contributed ) {
623 if ( Object.prototype.hasOwnProperty.call( contributed, ck ) ) {
624 hasContributed = true;
625 break;
626 }
627 }
628 if ( hasContributed || observe ) {
629 var existing = wpdHeadersToObject( init && init.headers );
630 if ( input && typeof input === 'object' && input.headers ) {
631 var fromReq = wpdHeadersToObject( input.headers );
632 for ( var rk in fromReq ) {
633 if ( Object.prototype.hasOwnProperty.call( fromReq, rk ) && ! ( rk in existing ) ) {
634 existing[ rk ] = fromReq[ rk ];
635 }
636 }
637 }
638 for ( var ck2 in contributed ) {
639 if ( Object.prototype.hasOwnProperty.call( contributed, ck2 ) ) {
640 existing[ ck2 ] = contributed[ ck2 ];
641 }
642 }
643 if ( hasContributed ) {
644 init = init ? Object.assign( {}, init ) : {};
645 init.headers = existing;
646 arguments[ 1 ] = init;
647 }
648 if ( observe ) {
649 requestHeaders = existing;
650 }
651 }
652
653 var promise;
654 try {
655 promise = wpdOrigFetch.apply( this, arguments );
656 } catch ( sync ) {
657 wpdReportNetwork( method, url, 0, 0, true, requestHeaders ? { requestHeaders: requestHeaders } : null );
658 throw sync;
659 }
660 return promise.then(
661 function ( res ) {
662 var dur = ( ( typeof performance !== 'undefined' && performance.now )
663 ? performance.now()
664 : Date.now() ) - start;
665 var extra = null;
666 if ( requestHeaders ) {
667 extra = { requestHeaders: requestHeaders };
668 try {
669 var rh = {};
670 if ( res && res.headers && typeof res.headers.forEach === 'function' ) {
671 res.headers.forEach( function ( v, k ) { rh[ k ] = v; } );
672 }
673 extra.responseHeaders = rh;
674 } catch ( _hErr ) { /* swallow */ }
675 }
676 wpdReportNetwork( method, url, res.status, Math.round( dur ), ! res.ok, extra );
677 wpdMaybeForceAuthCheck( res.status, url );
678 return res;
679 },
680 function ( err ) {
681 var dur = ( ( typeof performance !== 'undefined' && performance.now )
682 ? performance.now()
683 : Date.now() ) - start;
684 wpdReportNetwork( method, url, 0, Math.round( dur ), true, requestHeaders ? { requestHeaders: requestHeaders } : null );
685 throw err;
686 }
687 );
688 };
689 }
690
691 // Wrap XHR — admin-ajax runs through jQuery which runs through
692 // XHR, so fetch-only instrumentation would miss most of the
693 // legacy admin surface. Record method + URL on open; fire on
694 // loadend regardless of success / failure.
695 //
696 // Header contribution layer: `setRequestHeader` after open() but
697 // before send() — that's the only window the spec allows. The
698 // caller's own headers are tracked so observation can include
699 // them alongside the contributed ones.
700 if ( typeof XMLHttpRequest !== 'undefined' ) {
701 var wpdOrigOpen = XMLHttpRequest.prototype.open;
702 var wpdOrigSend = XMLHttpRequest.prototype.send;
703 var wpdOrigSetHeader = XMLHttpRequest.prototype.setRequestHeader;
704 XMLHttpRequest.prototype.open = function ( method, url ) {
705 try {
706 this.__wpdMethod = method;
707 this.__wpdUrl = url;
708 this.__wpdReqHeaders = {};
709 } catch ( _err ) { /* frozen instance — skip */ }
710 return wpdOrigOpen.apply( this, arguments );
711 };
712 XMLHttpRequest.prototype.setRequestHeader = function ( name, value ) {
713 try {
714 if ( ! this.__wpdReqHeaders ) {
715 this.__wpdReqHeaders = {};
716 }
717 this.__wpdReqHeaders[ name ] = value;
718 } catch ( _err ) { /* swallow */ }
719 return wpdOrigSetHeader.apply( this, arguments );
720 };
721 XMLHttpRequest.prototype.send = function () {
722 var xhr = this;
723 var start = ( typeof performance !== 'undefined' && performance.now )
724 ? performance.now()
725 : Date.now();
726
727 // Apply contributed headers right before send. Doing it
728 // here rather than in open() means contributions added
729 // after open() (e.g. in async-built request flows) still
730 // land on the wire.
731 var contributed = wpdContributedHeaders();
732 var observe = window.__wpdInstrument && window.__wpdInstrument.observe;
733 for ( var hk in contributed ) {
734 if ( Object.prototype.hasOwnProperty.call( contributed, hk ) ) {
735 try {
736 wpdOrigSetHeader.call( xhr, hk, contributed[ hk ] );
737 if ( ! xhr.__wpdReqHeaders ) {
738 xhr.__wpdReqHeaders = {};
739 }
740 xhr.__wpdReqHeaders[ hk ] = contributed[ hk ];
741 } catch ( _hErr ) { /* `setRequestHeader` rejects forbidden names — skip */ }
742 }
743 }
744
745 var fire = function () {
746 var dur = ( ( typeof performance !== 'undefined' && performance.now )
747 ? performance.now()
748 : Date.now() ) - start;
749 var extra = null;
750 if ( observe ) {
751 extra = {
752 requestHeaders: xhr.__wpdReqHeaders || {}
753 };
754 try {
755 var raw = xhr.getAllResponseHeaders ? xhr.getAllResponseHeaders() : '';
756 var resHeaders = {};
757 if ( raw && typeof raw === 'string' ) {
758 var lines = raw.trim().split( /[\r\n]+/ );
759 for ( var li = 0; li < lines.length; li++ ) {
760 var idx = lines[ li ].indexOf( ':' );
761 if ( idx > 0 ) {
762 resHeaders[ lines[ li ].slice( 0, idx ).trim() ] = lines[ li ].slice( idx + 1 ).trim();
763 }
764 }
765 }
766 extra.responseHeaders = resHeaders;
767 } catch ( _rErr ) { /* swallow */ }
768 }
769 wpdReportNetwork(
770 xhr.__wpdMethod,
771 xhr.__wpdUrl,
772 xhr.status,
773 Math.round( dur ),
774 xhr.status === 0 || xhr.status >= 400,
775 extra
776 );
777 wpdMaybeForceAuthCheck( xhr.status, xhr.__wpdUrl );
778 };
779 try {
780 xhr.addEventListener( 'loadend', fire );
781 } catch ( _err ) { /* swallow */ }
782 return wpdOrigSend.apply( this, arguments );
783 };
784 }
785
786 // Wrap sendBeacon — used by analytics + telemetry. The Beacon
787 // API doesn't accept headers (the entire point of beacons is
788 // minimal payload + best-effort delivery). When devtools have
789 // contributed headers we silently fall back to fetch with
790 // `keepalive: true`, which is the closest semantic match —
791 // guaranteed POST + same fire-and-forget intent + custom headers
792 // allowed. Without contributions we just relay the call.
793 if ( typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function' ) {
794 var wpdOrigBeacon = navigator.sendBeacon.bind( navigator );
795 navigator.sendBeacon = function ( url, data ) {
796 var contributed = wpdContributedHeaders();
797 var hasContributed = false;
798 for ( var ck in contributed ) {
799 if ( Object.prototype.hasOwnProperty.call( contributed, ck ) ) {
800 hasContributed = true;
801 break;
802 }
803 }
804 var start = ( typeof performance !== 'undefined' && performance.now )
805 ? performance.now()
806 : Date.now();
807 if ( ! hasContributed ) {
808 var ok = false;
809 try { ok = !! wpdOrigBeacon( url, data ); } catch ( _e ) { ok = false; }
810 wpdReportNetwork( 'POST', url, ok ? 200 : 0, 0, ! ok );
811 return ok;
812 }
813 try {
814 var observe = window.__wpdInstrument && window.__wpdInstrument.observe;
815 var headers = {};
816 for ( var hk2 in contributed ) {
817 if ( Object.prototype.hasOwnProperty.call( contributed, hk2 ) ) {
818 headers[ hk2 ] = contributed[ hk2 ];
819 }
820 }
821 window.fetch( url, {
822 method: 'POST',
823 body: data,
824 keepalive: true,
825 credentials: 'same-origin',
826 headers: headers
827 } ).then(
828 function ( res ) {
829 var dur = ( ( typeof performance !== 'undefined' && performance.now )
830 ? performance.now()
831 : Date.now() ) - start;
832 wpdReportNetwork( 'POST', url, res.status, Math.round( dur ), ! res.ok, observe ? { requestHeaders: headers } : null );
833 },
834 function () {
835 var dur = ( ( typeof performance !== 'undefined' && performance.now )
836 ? performance.now()
837 : Date.now() ) - start;
838 wpdReportNetwork( 'POST', url, 0, Math.round( dur ), true, observe ? { requestHeaders: headers } : null );
839 }
840 );
841 return true;
842 } catch ( _bErr ) {
843 return false;
844 }
845 };
846 }
847 } catch ( _err ) {
848 /* Whole observability block is best-effort. If something in
849 * the environment disagrees (frozen prototypes, CSP blocking
850 * postMessage, etc.) we don't want to tank the rest of the
851 * chromeless bridge. */
852 }
853
854 /*
855 * Menu-changed signal.
856 *
857 * The shell's dock is built from `$menu` at page-load time and
858 * then frozen — the iframe reload that follows plugin
859 * activation / deactivation / installation doesn't tell the
860 * parent the admin menu just mutated. This handler fires inside
861 * the iframe that JUST LOADED plugins.php (or a sibling menu-
862 * affecting page) and hands the parent a fresh payload the PHP
863 * side built server-side from the live $menu globals.
864 *
865 * Why not a REST roundtrip: plugins commonly gate their
866 * `admin_menu` registration on `is_admin()` evaluated AT PLUGIN
867 * LOAD. REST requests don't define `WP_ADMIN` at plugin-load
868 * time, so those plugins never register and a REST-context
869 * bootstrap can't retroactively make them. By capturing the
870 * payload here, inside a real admin context, we get the
871 * authoritative post-activation state that any REST endpoint
872 * would miss.
873 *
874 * Covered pages:
875 * - plugins.php — activate, deactivate, bulk, delete.
876 * - plugin-install.php — install new, install-and-activate.
877 * - update.php — update / install handler (install-
878 * plugin + upload-plugin actions).
879 * - themes.php — theme switch (rare but can add menus).
880 */
881 var __DESKTOP_MODE_MENU_PAYLOAD__ = /*__DESKTOP_MODE_MENU_PAYLOAD__*/;
882 var __DESKTOP_MODE_MENU_SIG__ = /*__DESKTOP_MODE_MENU_SIG__*/;
883 /*
884 * Icon harvest from the iframe's authoritative #adminmenu.
885 *
886 * The server-side payload only knows what the plugin set on
887 * $menu[$i][6]. Plugins that register their icon with 'none' /
888 * 'div' and paint it via a CSS rule on `#adminmenu .menu-icon-X`
889 * (All in One WP Migration, plus a long tail of older plugins)
890 * end up serialized with the gear fallback.
891 *
892 * On a regular page load the parent shell's resolveIcon() falls
893 * back to the parent's hidden #adminmenu DOM and reads the icon
894 * from there — but on a live activation the parent's #adminmenu
895 * is stale (it was rendered before the plugin existed). This
896 * iframe just rendered plugins.php in real admin context, so its
897 * own #adminmenu DOM IS authoritative; harvest each menu item's
898 * resolved icon here and patch the dockItems before postMessage.
899 */
900 try {
901 if (
902 __DESKTOP_MODE_MENU_PAYLOAD__
903 && Array.isArray( __DESKTOP_MODE_MENU_PAYLOAD__.dockItems )
904 ) {
905 var __wpdAdminMenu = document.getElementById( 'adminmenu' );
906 if ( __wpdAdminMenu ) {
907 var __wpdHarvest = {};
908 var __wpdLinks = __wpdAdminMenu.querySelectorAll( 'li.menu-top > a' );
909 for ( var __wpdLi = 0; __wpdLi < __wpdLinks.length; __wpdLi++ ) {
910 var __wpdLink = __wpdLinks[ __wpdLi ];
911 var __wpdKey;
912 try {
913 var __wpdU = new URL( __wpdLink.href || '', window.location.href );
914 __wpdKey = ( __wpdU.pathname.split( '/' ).pop() || '' ) + __wpdU.search;
915 } catch ( __wpdE1 ) { continue; }
916 if ( ! __wpdKey ) { continue; }
917 var __wpdImgWrap = __wpdLink.querySelector( '.wp-menu-image' );
918 if ( ! __wpdImgWrap ) { continue; }
919
920 /* (a) <img src> nested inside .wp-menu-image */
921 var __wpdImg = __wpdImgWrap.querySelector( 'img' );
922 if ( __wpdImg && __wpdImg.src ) {
923 __wpdHarvest[ __wpdKey ] = __wpdImg.src;
924 continue;
925 }
926
927 /* (b) dashicon class on the wrap div itself */
928 var __wpdDash = ( __wpdImgWrap.className || '' ).match( /\bdashicons-[\w-]+\b/ );
929 if (
930 __wpdDash
931 && __wpdDash[ 0 ] !== 'dashicons-before'
932 && __wpdDash[ 0 ] !== 'dashicons-admin-generic'
933 ) {
934 __wpdHarvest[ __wpdKey ] = __wpdDash[ 0 ];
935 continue;
936 }
937
938 /* (c) ::before background-image — pass the raw
939 * `url(...)` CSS value through; the parent's
940 * resolveIcon can hand it straight to _makeSvgIcon
941 * regardless of whether it's base64-encoded SVG,
942 * URL-encoded SVG, or a plain http(s) URL. */
943 try {
944 var __wpdBefore = window.getComputedStyle( __wpdImgWrap, '::before' );
945 var __wpdBg = __wpdBefore && __wpdBefore.backgroundImage;
946 if ( __wpdBg && __wpdBg !== 'none' && __wpdBg.indexOf( 'url("")' ) === -1 ) {
947 __wpdHarvest[ __wpdKey ] = __wpdBg;
948 continue;
949 }
950 /* (d) background on the wrap itself */
951 var __wpdWrapBg = window.getComputedStyle( __wpdImgWrap ).backgroundImage;
952 if ( __wpdWrapBg && __wpdWrapBg !== 'none' && __wpdWrapBg.indexOf( 'url("")' ) === -1 ) {
953 __wpdHarvest[ __wpdKey ] = __wpdWrapBg;
954 }
955 } catch ( __wpdE2 ) { /* getComputedStyle may throw on detached nodes */ }
956 }
957
958 var __wpdItems = __DESKTOP_MODE_MENU_PAYLOAD__.dockItems;
959 for ( var __wpdDi = 0; __wpdDi < __wpdItems.length; __wpdDi++ ) {
960 var __wpdItem = __wpdItems[ __wpdDi ];
961 if ( ! __wpdItem || __wpdItem.icon !== 'dashicons-admin-generic' ) { continue; }
962 if ( typeof __wpdItem.url !== 'string' || ! __wpdItem.url ) { continue; }
963 try {
964 var __wpdItemU = new URL( __wpdItem.url, window.location.href );
965 var __wpdItemKey = ( __wpdItemU.pathname.split( '/' ).pop() || '' ) + __wpdItemU.search;
966 if ( __wpdHarvest[ __wpdItemKey ] ) {
967 __wpdItem.icon = __wpdHarvest[ __wpdItemKey ];
968 }
969 } catch ( __wpdE3 ) { /* malformed url — leave icon alone */ }
970 }
971 }
972 }
973 } catch ( __wpdHarvestErr ) {
974 /* Harvest is best-effort; on any failure we still ship the
975 * server-built payload, which is exactly the pre-fix behavior. */
976 }
977 try {
978 if ( __DESKTOP_MODE_MENU_PAYLOAD__ ) {
979 window.parent.postMessage(
980 {
981 type: 'desktop-mode-plugins-changed',
982 payload: __DESKTOP_MODE_MENU_PAYLOAD__
983 },
984 window.location.origin
985 );
986 } else if ( __DESKTOP_MODE_MENU_SIG__ ) {
987 /*
988 * No full payload on this page — but we still ship the cheap
989 * menu signature so the shell can notice a menu change that
990 * happened somewhere off the plugins/themes/update path (a
991 * CPT registered via a settings tool, a plugin that adds a
992 * menu on save, …) and spend a refresh probe only then.
993 * GH#325.
994 */
995 window.parent.postMessage(
996 {
997 type: 'desktop-mode-menu-signature',
998 sig: __DESKTOP_MODE_MENU_SIG__
999 },
1000 window.location.origin
1001 );
1002 }
1003 } catch ( err ) {
1004 /* postMessage throws only on structured-clone failures, which
1005 * this static payload won't hit. Swallow defensively so a
1006 * wayward extension wrapping window.parent can't break the
1007 * rest of the bridge. */
1008 }
1009
1010 /*
1011 * Link & form interceptor.
1012 *
1013 * Every same-origin wp-admin <a> href and <form> action gets the
1014 * `desktop_mode_chromeless=1` flag appended so navigation inside the iframe stays
1015 * chromeless. Without this, a stray link to /wp-admin/edit.php (see
1016 * Gutenberg's fullscreen close button, help-tab links, "Return to
1017 * posts" affordances, etc.) re-renders the full classic admin inside
1018 * our window.
1019 *
1020 * Excluded from rewriting:
1021 * - modifier clicks (cmd/ctrl/shift/alt) — user wants to open a
1022 * new tab/window, respect that
1023 * - target="_blank" / target="_top" / target="_parent"
1024 * - download attribute
1025 * - in-page anchors (#)
1026 * - mailto:, tel:, javascript: schemes
1027 * - cross-origin URLs
1028 * - URLs that already carry desktop_mode_chromeless=
1029 */
1030 function rewriteAdminUrl( href, base ) {
1031 if ( ! href || href.charAt( 0 ) === '#' ) {
1032 return null;
1033 }
1034 if ( /^(mailto:|tel:|javascript:|data:)/i.test( href ) ) {
1035 return null;
1036 }
1037 var url;
1038 try {
1039 url = new URL( href, base );
1040 } catch ( err ) {
1041 return null;
1042 }
1043 if ( url.origin !== window.location.origin ) {
1044 return null;
1045 }
1046 if ( url.pathname.indexOf( '/wp-admin/' ) === -1 ) {
1047 return null;
1048 }
1049 if ( url.searchParams.has( 'desktop_mode_chromeless' ) ) {
1050 return null;
1051 }
1052 url.searchParams.set( 'desktop_mode_chromeless', '1' );
1053 return url.toString();
1054 }
1055
1056 /*
1057 * Classify a link so we know whether to rewrite it (admin),
1058 * escalate it to the parent shell (external / non-admin), or let
1059 * the browser navigate naturally (mailto, anchor, download, etc.).
1060 *
1061 * 'admin' — same-origin /wp-admin/ URL we rewrite in place.
1062 * 'external' — http(s) URL we want the parent shell to open
1063 * as a sub-tab instead of navigating the iframe
1064 * out of wp-admin. Covers both cross-origin
1065 * links (plugin author sites, external docs) AND
1066 * same-origin non-admin links (the site's own
1067 * front-end pages).
1068 * 'passthrough' — anything else (mailto, tel, javascript, data,
1069 * anchors, unparseable). The browser handles it.
1070 */
1071 function classifyLink( href, base ) {
1072 if ( ! href || href.charAt( 0 ) === '#' ) {
1073 return 'passthrough';
1074 }
1075 if ( /^(mailto:|tel:|javascript:|data:)/i.test( href ) ) {
1076 return 'passthrough';
1077 }
1078 var url;
1079 try {
1080 url = new URL( href, base );
1081 } catch ( err ) {
1082 return 'passthrough';
1083 }
1084 if ( url.protocol !== 'http:' && url.protocol !== 'https:' ) {
1085 return 'passthrough';
1086 }
1087 if (
1088 url.origin === window.location.origin &&
1089 url.pathname.indexOf( '/wp-admin/' ) !== -1
1090 ) {
1091 return 'admin';
1092 }
1093 return 'external';
1094 }
1095
1096 document.addEventListener( 'click', function ( e ) {
1097 if ( e.defaultPrevented ) {
1098 return;
1099 }
1100 if ( e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey ) {
1101 return;
1102 }
1103 var link = e.target && e.target.closest ? e.target.closest( 'a[href]' ) : null;
1104 if ( ! link ) {
1105 return;
1106 }
1107 if ( link.target && link.target !== '' && link.target !== '_self' ) {
1108 return;
1109 }
1110 if ( link.hasAttribute( 'download' ) ) {
1111 return;
1112 }
1113 /*
1114 * Activity-footprint launcher. A "View activity footprint" row
1115 * action (added to the Users list table by
1116 * `desktop_mode_user_footprint_row_action`) carries the target
1117 * user id in `data-desktop-mode-footprint`. The iframe has no
1118 * shell API of its own, so we escalate the click to the parent
1119 * shell, which opens the My WordPress window on that user's
1120 * footprint. Checked BEFORE classifyLink so the link's real
1121 * href — a graceful profile-edit fallback for no-JS — is never
1122 * followed inside the shell. Modifier-key / middle clicks are
1123 * already filtered above, so cmd/ctrl-click still opens that
1124 * fallback in a new browser tab.
1125 */
1126 var footprintAttr = link.getAttribute( 'data-desktop-mode-footprint' );
1127 if ( footprintAttr ) {
1128 var footprintUid = parseInt( footprintAttr, 10 );
1129 if ( footprintUid > 0 ) {
1130 e.preventDefault();
1131 try {
1132 window.parent.postMessage(
1133 {
1134 type: 'desktop-mode-open-user-footprint',
1135 userId: footprintUid,
1136 userName: link.getAttribute( 'data-desktop-mode-footprint-name' ) || ''
1137 },
1138 window.location.origin
1139 );
1140 } catch ( footprintErr ) {
1141 /* Same-origin postMessage can only fail in a sandbox
1142 * we don't support — swallow rather than block the
1143 * click. */
1144 }
1145 return;
1146 }
1147 }
1148 /*
1149 * WordPress core's wp-admin/js/updates.js owns the click on these
1150 * AJAX-driven plugin/theme management buttons — it binds in bubble
1151 * phase and calls preventDefault to take over with an in-place
1152 * AJAX install / update / delete (with its own progress spinner
1153 * and inline success/failure UX). Our capture-phase handler would
1154 * preempt it: preventDefault here fires BEFORE updates.js's own,
1155 * the AJAX call never starts, and the postMessage below diverts
1156 * the user to the link's no-JS fallback URL (update.php?action=
1157 * install-plugin&...) opened as a freshly spawned desktop window.
1158 * That fallback technically completes the install server-side,
1159 * but it's a long blocking page-load with no in-place feedback —
1160 * which is what users perceive as "Install Now keeps loading and
1161 * opens a new tab". Skip these classes so updates.js's bubble
1162 * handler runs as core intended.
1163 *
1164 * The plugins-list-table row action "Delete" is the same story
1165 * with a different marker: a bare `a.delete` inside a
1166 * `tr[data-plugin]` (updates.js binds `[data-plugin] a.delete`;
1167 * the network themes list is `.themes-php.network-admin
1168 * a.delete`) — it never carries the `delete-plugin` /
1169 * `delete-theme` classes of the card-style buttons above.
1170 * Hijacking it navigated the iframe to the link's no-JS
1171 * bulk-delete fallback WHILE updates.js's AJAX delete was
1172 * already running: `wp.updates.beforeunload` raised a native
1173 * "Leave site?" prompt, and leaving landed on a delete
1174 * confirmation screen for a plugin whose files the AJAX call
1175 * had just removed — an empty "You are about to remove:" list.
1176 */
1177 if (
1178 link.classList.contains( 'install-now' ) ||
1179 link.classList.contains( 'update-link' ) ||
1180 link.classList.contains( 'update-now' ) ||
1181 link.classList.contains( 'delete-plugin' ) ||
1182 link.classList.contains( 'delete-theme' ) ||
1183 link.classList.contains( 'install-theme' ) ||
1184 ( link.classList.contains( 'delete' ) &&
1185 ( link.closest( '[data-plugin]' ) ||
1186 ( document.body.classList.contains( 'themes-php' ) &&
1187 document.body.classList.contains( 'network-admin' ) ) ) )
1188 ) {
1189 return;
1190 }
1191 var href = link.getAttribute( 'href' );
1192 var kind = classifyLink( href, window.location.href );
1193 if ( kind === 'admin' ) {
1194 var rewritten = rewriteAdminUrl( href, window.location.href );
1195 if ( rewritten ) {
1196 link.setAttribute( 'href', rewritten );
1197 }
1198 /*
1199 * Hand admin-internal navigation to the parent shell.
1200 *
1201 * The parent decides what to do with each click:
1202 *
1203 * - Native-window remap hits (e.g. `edit.php` while the
1204 * user has the native Posts opt-in on) → parent opens
1205 * the native window and closes THIS iframe.
1206 * - Same-page nav (pagination, filtering on the same
1207 * `edit.php?post_type=page` screen, etc.) → parent
1208 * drives the iframe's `location.assign()` so the
1209 * in-place navigation matches the user's intent.
1210 * - Cross-page nav (e.g. clicking "Posts" from inside
1211 * the Pages window) → parent opens a new window for
1212 * the destination and leaves THIS iframe untouched,
1213 * so the user keeps both contexts.
1214 *
1215 * We `preventDefault()` so the iframe never starts a
1216 * navigation the parent might want to suppress; otherwise
1217 * cross-page clicks would trash the source window before
1218 * the parent had a chance to react. Modifier-key clicks
1219 * (cmd/ctrl/shift/alt, middle-click) are already filtered
1220 * upstream so the browser's native "open in new tab" path
1221 * still works.
1222 */
1223 e.preventDefault();
1224 try {
1225 var absolute = new URL( rewritten || href, window.location.href ).toString();
1226 /*
1227 * Ship the link's visible text along with the URL so
1228 * the parent can title a freshly-opened window with
1229 * something the user recognises ("Scheduler") instead
1230 * of the URL slug ("tools-php-page-scheduler") when
1231 * the destination has no dock tile to copy a title
1232 * from. The iframe itself never auto-emits a
1233 * title-change, so without this hint the slug-as-
1234 * title fallback would persist for the lifetime of
1235 * the new window.
1236 */
1237 var adminLabel = ( link.textContent || '' ).trim() ||
1238 link.getAttribute( 'title' ) ||
1239 link.getAttribute( 'aria-label' ) ||
1240 '';
1241 window.parent.postMessage(
1242 {
1243 type: 'desktop-mode-iframe-admin-link',
1244 url: absolute,
1245 label: adminLabel.slice( 0, 80 )
1246 },
1247 window.location.origin
1248 );
1249 } catch ( bridgeErr ) {
1250 /* Same-origin postMessage to the same window can only fail in
1251 * a sandbox we don't support — swallow rather than block the
1252 * click. */
1253 }
1254 return;
1255 }
1256 if ( kind === 'external' ) {
1257 /*
1258 * External navigation inside an admin iframe would leave
1259 * the user stranded in a chrome-free version of whatever
1260 * site the link points at. Escalate to the parent shell
1261 * so it opens the URL as a closeable sub-tab (with a
1262 * detach button) alongside the admin tab — the user
1263 * stays inside the desktop shell.
1264 *
1265 * Resolving the href against the document base gives the
1266 * parent an absolute URL it doesn't have to re-resolve.
1267 */
1268 e.preventDefault();
1269 var absolute;
1270 try {
1271 absolute = new URL( href, window.location.href ).toString();
1272 } catch ( err ) {
1273 return;
1274 }
1275 var label = ( link.textContent || '' ).trim() ||
1276 link.getAttribute( 'title' ) ||
1277 absolute;
1278 window.parent.postMessage(
1279 {
1280 type: 'desktop-mode-external-link',
1281 url: absolute,
1282 label: label.slice( 0, 80 )
1283 },
1284 window.location.origin
1285 );
1286 }
1287 }, true );
1288
1289 document.addEventListener( 'submit', function ( e ) {
1290 var form = e.target;
1291 if ( ! form || form.tagName !== 'FORM' ) {
1292 return;
1293 }
1294 var action = form.getAttribute( 'action' );
1295 var rewritten = rewriteAdminUrl( action || window.location.href, window.location.href );
1296 if ( rewritten ) {
1297 form.setAttribute( 'action', rewritten );
1298 }
1299 }, true );
1300
1301 /*
1302 * Focus-request bridge.
1303 *
1304 * Clicks inside an iframe don't cross the browsing-context
1305 * boundary — the parent shell's pointerdown / focusin listeners
1306 * never see them, so without this hook the only way to focus an
1307 * iframe window would be clicking its title bar chrome. Post a
1308 * `desktop-mode-focus-request` message on every pointerdown; the
1309 * parent Window class treats it as an onFocusRequest. Capture
1310 * phase so the signal fires before any stopPropagation inside
1311 * a page's own handlers.
1312 */
1313 document.addEventListener( 'pointerdown', function () {
1314 try {
1315 window.parent.postMessage(
1316 { type: 'desktop-mode-focus-request' },
1317 window.location.origin
1318 );
1319 } catch ( err ) {
1320 /* cross-origin parent (shouldn't happen for chromeless
1321 * pages, but don't let a throw break the bridge) */
1322 }
1323 }, true );
1324
1325 /*
1326 * OS-file drop forwarder. When the user drags a file from the
1327 * host OS into a chromeless admin iframe, intercept the drop
1328 * before the browser's default "navigate the iframe to the
1329 * file" handler fires, and `postMessage` the raw `File[]` up
1330 * to the parent shell so the OS-file drop manager
1331 * (`src/os-file-drop/manager.ts`) can show the upload dialog.
1332 *
1333 * Same-origin postMessage preserves `File` identity — the
1334 * parent receives real `File` objects, no base64 round-trip.
1335 *
1336 * We only intercept drops whose `DataTransfer.types` includes
1337 * `'Files'`. In-page DnD (Gutenberg block reorders, media
1338 * library drags) carries non-`Files` types and passes through
1339 * untouched.
1340 */
1341 function bridgeHasFiles( ev ) {
1342 var t = ev && ev.dataTransfer && ev.dataTransfer.types;
1343 if ( ! t ) {
1344 return false;
1345 }
1346 if ( typeof t.includes === 'function' ) {
1347 return t.includes( 'Files' );
1348 }
1349 if ( typeof t.contains === 'function' ) {
1350 return t.contains( 'Files' );
1351 }
1352 for ( var i = 0; i < t.length; i++ ) {
1353 if ( t[ i ] === 'Files' ) {
1354 return true;
1355 }
1356 }
1357 return false;
1358 }
1359 /*
1360 * Selectors of in-iframe drop receivers we leave alone —
1361 * Gutenberg's drop zone, the legacy media uploader, any
1362 * element a plugin marks with `data-drop-zone`. The whole
1363 * point: file drops onto Gutenberg blocks keep firing
1364 * Gutenberg's handler; only drops on the empty page
1365 * background escalate to the shell.
1366 */
1367 var bridgeDropPassthroughSelectors = [
1368 '.components-drop-zone',
1369 '[data-drop-zone]',
1370 '.uploader-window',
1371 '.media-frame-content'
1372 ];
1373 function bridgeDropTargetWantsFile( target ) {
1374 if ( ! target || ! target.closest ) {
1375 return false;
1376 }
1377 for ( var s = 0; s < bridgeDropPassthroughSelectors.length; s++ ) {
1378 if ( target.closest( bridgeDropPassthroughSelectors[ s ] ) ) {
1379 return true;
1380 }
1381 }
1382 return false;
1383 }
1384 /*
1385 * Bubble phase (not capture): the inner-most handler — Gutenberg's
1386 * drop zone, the legacy media uploader, or a third-party plugin
1387 * like "Administrador de archivos WP" — runs FIRST and gets the
1388 * chance to call `preventDefault()` to claim the drop. Our
1389 * forwarder then runs LAST at the document level and yields to
1390 * anyone who already took ownership.
1391 *
1392 * Two bail conditions, in order:
1393 * 1. `bridgeDropTargetWantsFile()` — the curated allowlist
1394 * (Gutenberg, wp.media, anything tagged `[data-drop-zone]`).
1395 * Kept as the primary check so the well-known core surfaces
1396 * behave identically to before, even if some edge case skips
1397 * the `preventDefault()` step.
1398 * 2. `ev.defaultPrevented` — the universal HTML5 contract: any
1399 * drop zone willing to receive a file calls `preventDefault()`
1400 * on `dragover` (mandatory per spec) and `drop` (to suppress
1401 * the browser's default navigate-to-file). When that's true,
1402 * some inner handler has taken the drop — yield so plugins
1403 * outside the allowlist (WP File Manager, Yoast, etc.) keep
1404 * their native UX.
1405 */
1406 document.addEventListener( 'dragover', function ( ev ) {
1407 if ( ! bridgeHasFiles( ev ) ) {
1408 return;
1409 }
1410 if ( bridgeDropTargetWantsFile( ev.target ) ) {
1411 return;
1412 }
1413 if ( ev.defaultPrevented ) {
1414 return;
1415 }
1416 ev.preventDefault();
1417 if ( ev.dataTransfer ) {
1418 ev.dataTransfer.dropEffect = 'copy';
1419 }
1420 }, false );
1421 document.addEventListener( 'drop', function ( ev ) {
1422 if ( ! bridgeHasFiles( ev ) ) {
1423 return;
1424 }
1425 if ( bridgeDropTargetWantsFile( ev.target ) ) {
1426 return;
1427 }
1428 if ( ev.defaultPrevented ) {
1429 return;
1430 }
1431 ev.preventDefault();
1432 ev.stopPropagation();
1433 var files = [];
1434 if ( ev.dataTransfer && ev.dataTransfer.files ) {
1435 for ( var i = 0; i < ev.dataTransfer.files.length; i++ ) {
1436 files.push( ev.dataTransfer.files[ i ] );
1437 }
1438 }
1439 if ( files.length === 0 ) {
1440 return;
1441 }
1442 try {
1443 window.parent.postMessage(
1444 {
1445 type: 'desktop-mode-os-file-drop',
1446 files: files,
1447 x: ev.clientX,
1448 y: ev.clientY,
1449 },
1450 window.location.origin
1451 );
1452 } catch ( err ) { /* cross-origin parent; swallow */ }
1453 }, false );
1454
1455 /*
1456 * Drag-hover forwarder. Native drag events don't cross iframe
1457 * boundaries, so when the user holds ANY drag (an OS file, an
1458 * image lifted off another admin page, a text selection) over
1459 * this window, the parent shell has no idea the window is being
1460 * hovered. Forward a throttled, payload-free heartbeat so the
1461 * shell's focus-on-drag-hover module
1462 * (`src/drag/focus-window-on-drag-hover.ts`) can raise this
1463 * window after its dwell. Purely observational — no
1464 * `preventDefault()`, no interference with in-page drop zones.
1465 * The parent identifies the hovered window from the message
1466 * source, so no coordinates travel.
1467 *
1468 * Sentinel-guarded: the standalone bridge bundle
1469 * (`iframe-bridge-standalone.ts`) installs the same forwarder,
1470 * and unlike the drop forwarder above there is no
1471 * `defaultPrevented` handshake to dedupe a double install.
1472 */
1473 if ( ! window.__desktopModeDragHoverForwarderInstalled ) {
1474 window.__desktopModeDragHoverForwarderInstalled = true;
1475 var dragHoverLastSent = 0;
1476 document.addEventListener( 'dragover', function ( ev ) {
1477 var now = Date.now();
1478 if ( now - dragHoverLastSent < 150 ) {
1479 return;
1480 }
1481 dragHoverLastSent = now;
1482 try {
1483 window.parent.postMessage(
1484 {
1485 type: 'desktop-mode-drag-hover',
1486 payloadType: bridgeHasFiles( ev ) ? 'os-file' : 'external',
1487 },
1488 window.location.origin
1489 );
1490 } catch ( err ) { /* cross-origin parent; swallow */ }
1491 }, true );
1492 }
1493
1494 /*
1495 * Cmd+K / Ctrl+K forwarder — single-press, unconditional.
1496 *
1497 * Native keydown events don't cross iframe boundaries. Inside a
1498 * chromeless admin page we want exactly ONE command palette: the
1499 * desktop shell's. WordPress's own `core/commands` palette is
1500 * harvested by `__wpdHarvestCommands` below and re-surfaced in the
1501 * shell palette, so there's no reason to ever let the in-page palette
1502 * take the keystroke.
1503 *
1504 * Capture phase + `stopImmediatePropagation` so we win the race
1505 * against Gutenberg / TinyMCE / plugin handlers bound to the same
1506 * shortcut. Shift/Alt modifiers pass through so user shortcuts using
1507 * those combos keep working.
1508 */
1509 document.addEventListener( 'keydown', function ( e ) {
1510 if ( ! ( e.metaKey || e.ctrlKey ) ) return;
1511 if ( e.key !== 'k' && e.key !== 'K' ) return;
1512 if ( e.shiftKey || e.altKey ) return;
1513
1514 e.preventDefault();
1515 e.stopImmediatePropagation();
1516
1517 try {
1518 window.parent.postMessage(
1519 { type: 'desktop-mode-palette-cycle' },
1520 window.location.origin
1521 );
1522 } catch ( err ) { /* cross-origin parent; swallow */ }
1523 }, true );
1524
1525 /*
1526 * Command harvester — bridges `wp.data.select('core/commands')` to
1527 * the parent shell.
1528 *
1529 * On `desktop-mode-commands-subscribe` from the parent, subscribe to
1530 * the `core/commands` store and post `desktop-mode-commands-list` on
1531 * every change (de-duplicated). On `desktop-mode-commands-invoke`, run
1532 * the original callback inside this iframe — the parent fires this
1533 * when the user selects a proxied command from the shell palette.
1534 *
1535 * Commands are classified by dry-invoking their callback inside a
1536 * `window.location`-intercept sandbox: pure-navigation callbacks
1537 * are flagged `navigate` (with the captured URL) so the parent can
1538 * open a new desktop window instead of navigating this iframe out
1539 * of chromeless mode. Everything else is `action` and proxies back
1540 * into this iframe on user selection.
1541 */
1542 var __wpdCommandsSubscribed = false;
1543 var __wpdCommandsLastPayload = '';
1544 var __wpdCommandsDebounceId = null;
1545 var __wpdCommandsOrigin = window.location.origin;
1546 // Cache per command name so the `window.location`-intercept
1547 // sandbox only runs once per command. Re-classifying on every
1548 // store tick would repeatedly fire side-effectful action
1549 // callbacks (preference toggles, modal opens) — unacceptable.
1550 // Keyed by name; value is the frozen classification minus the
1551 // live `label` / `icon` (which we always re-read in case the
1552 // command updated its own metadata).
1553 var __wpdCommandsKindCache = Object.create( null );
1554
1555 function __wpdRenderIconElement( icon ) {
1556 if ( ! icon ) return '';
1557 if ( typeof icon === 'string' ) return '';
1558 if ( ! window.wp || ! window.wp.element || typeof window.wp.element.renderToString !== 'function' ) {
1559 return '';
1560 }
1561 try {
1562 var rendered = window.wp.element.renderToString( icon );
1563 // `@wordpress/icons` entries render as a complete `<svg>`
1564 // tag. Anything else (wrapped components, empty fragments,
1565 // strings) falls back to dashicons in the palette — we only
1566 // accept markup we can inject straight into the icon slot.
1567 if ( typeof rendered === 'string' && rendered.toLowerCase().indexOf( '<svg' ) === 0 ) {
1568 return rendered;
1569 }
1570 } catch ( _err ) { /* swallow */ }
1571 return '';
1572 }
1573
1574 function __wpdClassifyCommand( cmd ) {
1575 // Defensive defaults — a broken registry should not tank the bridge.
1576 var out = {
1577 name: String( cmd && cmd.name ? cmd.name : '' ),
1578 label: String( cmd && cmd.label ? cmd.label : '' ),
1579 icon: cmd && cmd.icon && typeof cmd.icon === 'string' ? cmd.icon : undefined,
1580 iconSvg: undefined,
1581 context: cmd && cmd.context ? String( cmd.context ) : undefined,
1582 kind: 'action',
1583 url: undefined
1584 };
1585 if ( ! cmd || typeof cmd.callback !== 'function' ) {
1586 return out;
1587 }
1588
1589 // Short-circuit on cached classifications — `renderToString` on
1590 // the React icon is expensive, and the static URL regex scan
1591 // on `callback.toString()` is pure CPU we've already paid once.
1592 var cached = __wpdCommandsKindCache[ out.name ];
1593 if ( cached ) {
1594 out.kind = cached.kind;
1595 out.url = cached.url;
1596 out.iconSvg = cached.iconSvg;
1597 return out;
1598 }
1599
1600 // Render the React icon once per command name — Gutenberg
1601 // commands ship `icon` as a `@wordpress/icons` React element
1602 // the postMessage bridge can't serialize, so we flatten it to
1603 // a static SVG string here.
1604 if ( cmd.icon && typeof cmd.icon !== 'string' ) {
1605 out.iconSvg = __wpdRenderIconElement( cmd.icon );
1606 }
1607
1608 // STATIC classification — read the callback's source text and
1609 // look for a string-literal navigation target. We deliberately
1610 // do NOT execute the callback. An earlier iteration tried a
1611 // dry-run with a `window.location` intercept sandbox, but
1612 // `Location.prototype.href` is non-configurable: the shim
1613 // silently failed, every nav callback actually navigated the
1614 // iframe, the new page re-harvested, and the cascade opened
1615 // windows forever.
1616 //
1617 // Cases caught (WP's @wordpress/core-commands callbacks are
1618 // all of this shape):
1619 // document.location.href = 'url'
1620 // window.location.href = "url"
1621 // location.href = `url`
1622 // location.assign( 'url' )
1623 // location.replace( 'url' )
1624 //
1625 // Computed URLs (template-literal interpolation, addQueryArgs
1626 // calls, variables) fall back to `action` — the user picking
1627 // them will still run the real callback inside the iframe,
1628 // which is the safe default.
1629 var src = '';
1630 try { src = Function.prototype.toString.call( cmd.callback ); } catch ( _err ) { src = ''; }
1631 var navRe = /(?:document\.location\.href|window\.location\.href|location\.href)\s*=\s*['"]([^'"$]+?)['"]/;
1632 var asgRe = /location\.(?:assign|replace)\s*\(\s*['"]([^'"$]+?)['"]\s*\)/;
1633 var mm = src.match( navRe ) || src.match( asgRe );
1634 if ( mm && mm[ 1 ] ) {
1635 try {
1636 out.url = new URL( mm[ 1 ], window.location.href ).toString();
1637 out.kind = 'navigate';
1638 } catch ( _err ) {
1639 out.kind = 'action';
1640 }
1641 }
1642 __wpdCommandsKindCache[ out.name ] = { kind: out.kind, url: out.url, iconSvg: out.iconSvg };
1643 return out;
1644 }
1645
1646 // Harvested commands accumulate here. The React harvester writes
1647 // the full list each render; `__wpdPostCommandsList` reads + posts.
1648 var __wpdLastRawCommands = [];
1649 // Name → live `callback` reference. Loader-returned commands are
1650 // NOT in `wp.data.select('core/commands').getCommands()` — the
1651 // store only exposes statically-registered entries. Without a
1652 // private cache keyed off the React harvester's most recent render,
1653 // invoking a loader command from the parent palette ("Duplicate
1654 // block", "Transform to...", pattern commands) would silently fall
1655 // through to the `getCommands()` lookup and no-op.
1656 var __wpdCommandCallbacks = Object.create( null );
1657
1658 function __wpdFinalizeCommands( raw ) {
1659 var seen = Object.create( null );
1660 var out = [];
1661 var skipped = { missing: 0, disabled: 0, dup: 0 };
1662 for ( var i = 0; i < raw.length; i++ ) {
1663 var cmd = raw[ i ];
1664 if ( ! cmd || ! cmd.name || ! cmd.label ) { skipped.missing++; continue; }
1665 if ( cmd.disabled ) { skipped.disabled++; continue; }
1666 if ( seen[ cmd.name ] ) { skipped.dup++; continue; }
1667 seen[ cmd.name ] = true;
1668 out.push( __wpdClassifyCommand( cmd ) );
1669 }
1670 return out;
1671 }
1672
1673 function __wpdHarvestCommands() {
1674 return __wpdFinalizeCommands( __wpdLastRawCommands );
1675 }
1676
1677 // React-mounted harvester. Block-level / editor-contextual commands
1678 // (tier 3 loaders like `core/block-editor/selected-block-commands`,
1679 // `core/edit-post/pattern-commands`) are React *hooks* — they call
1680 // `useSelect` internally, which only works inside a function-
1681 // component render. So we mount an invisible React tree whose
1682 // children invoke each loader's hook at render time. On every
1683 // re-render (block selection changes, entity edits, welcome guide
1684 // toggled) the effect re-posts the fresh command list to the
1685 // parent. One component per loader keeps the rules-of-hooks
1686 // contract — the hook count inside each `LoaderSlot` is fixed at
1687 // one call (plus the constant `useEffect`), so React's reconciler
1688 // is happy.
1689 var __wpdReactMounted = false;
1690 // Stashed so `__wpdUnsubscribeCommands` can tear the harvester
1691 // down when focus leaves the window — otherwise the component
1692 // keeps re-rendering on every store tick, calling `mergeAndPost`,
1693 // and posting command lists the parent drops on the floor.
1694 var __wpdReactRoot = null;
1695 var __wpdReactHost = null;
1696
1697 function __wpdMountReactHarvester() {
1698 if ( __wpdReactMounted ) return;
1699 if ( ! window.wp || ! window.wp.element || ! window.wp.data ) {
1700 return;
1701 }
1702 var el = window.wp.element;
1703 var createEl = el.createElement;
1704 var useEffect = el.useEffect;
1705 var useRef = el.useRef;
1706 var useMemo = el.useMemo;
1707 var useSelect = ( window.wp.data && window.wp.data.useSelect ) || null;
1708 if ( ! createEl || ! useSelect || ! el.createRoot || ! useRef ) {
1709 return;
1710 }
1711 __wpdReactMounted = true;
1712
1713 // Hidden mount point. Positioned off-screen + `aria-hidden` so
1714 // nothing the harvester renders (it renders null anyway) can
1715 // leak into the accessibility tree or the visible document.
1716 var host = document.createElement( 'div' );
1717 host.setAttribute( 'aria-hidden', 'true' );
1718 host.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden;pointer-events:none;left:-9999px;top:-9999px;';
1719 ( document.body || document.documentElement ).appendChild( host );
1720 __wpdReactHost = host;
1721
1722 // Shared mutable bucket — ref-based aggregation to avoid the
1723 // classic setState-inside-useEffect loop. A `setState` here
1724 // would fire a parent re-render, which would fire the loader
1725 // hook again, which returns a fresh commands array with a new
1726 // reference even when the contents are identical, which would
1727 // re-fire the effect and setState again → Maximum update
1728 // depth exceeded. Refs don't trigger renders, so the loop is
1729 // broken even when hooks churn references.
1730 var resultsBucket = { perLoader: {}, statics: [], loadersList: [] };
1731
1732 function commandsFingerprint( cmds ) {
1733 if ( ! Array.isArray( cmds ) || cmds.length === 0 ) return '';
1734 // Cheap identity — name count is enough to decide whether
1735 // to re-post. Accepts some false negatives (two different
1736 // commands sharing a name) we'll never hit in practice.
1737 var keys = new Array( cmds.length );
1738 for ( var i = 0; i < cmds.length; i++ ) {
1739 var c = cmds[ i ];
1740 keys[ i ] = c && c.name ? c.name : '';
1741 }
1742 return keys.join( '|' );
1743 }
1744
1745 function mergeAndPost() {
1746 var merged = [];
1747 var loadersList = resultsBucket.loadersList;
1748 if ( Array.isArray( loadersList ) ) {
1749 for ( var i = 0; i < loadersList.length; i++ ) {
1750 var bucket = resultsBucket.perLoader[ loadersList[ i ] ];
1751 if ( Array.isArray( bucket ) ) merged = merged.concat( bucket );
1752 }
1753 }
1754 if ( Array.isArray( resultsBucket.statics ) ) {
1755 merged = merged.concat( resultsBucket.statics );
1756 }
1757 // Refresh the callback cache off the SAME snapshot we're
1758 // about to post. Loader-returned commands close over React
1759 // state (selected block, edited entity, etc.) that's only
1760 // valid for this render pass, so rebuilding from scratch
1761 // every merge keeps invoke-from-parent honest instead of
1762 // calling a stale closure.
1763 __wpdCommandCallbacks = Object.create( null );
1764 for ( var j = 0; j < merged.length; j++ ) {
1765 var cc = merged[ j ];
1766 if ( cc && cc.name && typeof cc.callback === 'function' ) {
1767 __wpdCommandCallbacks[ cc.name ] = cc.callback;
1768 }
1769 }
1770 __wpdLastRawCommands = merged;
1771 __wpdSchedulePost();
1772 }
1773
1774 // One slot per loader. Calls the loader's hook at render time;
1775 // an effect keyed on the commands' name-fingerprint writes the
1776 // fresh list into the shared bucket and posts. Ref-based, no
1777 // setState → no re-render cascade.
1778 function LoaderSlot( props ) {
1779 var loader = props.loader;
1780 var result = null;
1781 try {
1782 result = loader.hook( { search: '' } );
1783 } catch ( _err ) {
1784 /* swallow — a buggy loader hook shouldn't take the harvester down */
1785 }
1786 var cmds = ( result && Array.isArray( result.commands ) ) ? result.commands : [];
1787 var key = useMemo( function () { return commandsFingerprint( cmds ); }, [ cmds ] );
1788
1789 useEffect( function () {
1790 resultsBucket.perLoader[ loader.name ] = cmds;
1791 mergeAndPost();
1792 }, [ key ] );
1793
1794 useEffect( function () {
1795 return function () {
1796 delete resultsBucket.perLoader[ loader.name ];
1797 mergeAndPost();
1798 };
1799 }, [] );
1800
1801 return null;
1802 }
1803
1804 function Harvester() {
1805 var loaders = useSelect( function ( s ) {
1806 var ss = s( 'core/commands' );
1807 return ( ss && typeof ss.getCommandLoaders === 'function' )
1808 ? ss.getCommandLoaders( true )
1809 : [];
1810 }, [] );
1811 var staticCmds = useSelect( function ( s ) {
1812 var ss = s( 'core/commands' );
1813 return ( ss && typeof ss.getCommands === 'function' )
1814 ? ss.getCommands( true )
1815 : [];
1816 }, [] );
1817
1818 // Track the loader-name ordering so `mergeAndPost` can emit
1819 // tier-3 in a deterministic order (React reconciliation
1820 // order = registration order = the order the user sees).
1821 var loadersNames = useMemo( function () {
1822 if ( ! Array.isArray( loaders ) ) return [];
1823 return loaders.map( function ( l ) { return l ? l.name : ''; } );
1824 }, [ loaders ] );
1825 var loadersKey = loadersNames.join( '|' );
1826 useEffect( function () {
1827 resultsBucket.loadersList = loadersNames;
1828 mergeAndPost();
1829 }, [ loadersKey ] );
1830
1831 var staticKey = useMemo( function () { return commandsFingerprint( staticCmds ); }, [ staticCmds ] );
1832 useEffect( function () {
1833 resultsBucket.statics = Array.isArray( staticCmds ) ? staticCmds : [];
1834 mergeAndPost();
1835 }, [ staticKey ] );
1836
1837 if ( ! Array.isArray( loaders ) || loaders.length === 0 ) {
1838 return null;
1839 }
1840 var children = [];
1841 for ( var i = 0; i < loaders.length; i++ ) {
1842 var loader = loaders[ i ];
1843 if ( ! loader || typeof loader.hook !== 'function' ) continue;
1844 children.push( createEl( LoaderSlot, {
1845 key: loader.name,
1846 loader: loader
1847 } ) );
1848 }
1849 return createEl( el.Fragment || 'div', null, children );
1850 }
1851
1852 try {
1853 var root = el.createRoot( host );
1854 __wpdReactRoot = root;
1855 root.render( createEl( Harvester ) );
1856 } catch ( err ) {
1857 __wpdReactMounted = false;
1858 __wpdReactRoot = null;
1859 if ( __wpdReactHost && __wpdReactHost.parentNode ) {
1860 __wpdReactHost.parentNode.removeChild( __wpdReactHost );
1861 }
1862 __wpdReactHost = null;
1863 }
1864 }
1865
1866 function __wpdUnmountReactHarvester() {
1867 if ( __wpdReactRoot ) {
1868 try { __wpdReactRoot.unmount(); } catch ( _err ) { /* swallow */ }
1869 }
1870 __wpdReactRoot = null;
1871 if ( __wpdReactHost && __wpdReactHost.parentNode ) {
1872 __wpdReactHost.parentNode.removeChild( __wpdReactHost );
1873 }
1874 __wpdReactHost = null;
1875 __wpdReactMounted = false;
1876 __wpdLastRawCommands = [];
1877 __wpdCommandCallbacks = Object.create( null );
1878 }
1879
1880 function __wpdPostCommandsList() {
1881 var list = __wpdHarvestCommands();
1882 // Cheap de-dupe — the store fires on every unrelated preference
1883 // change too, and shipping an identical payload is pure noise.
1884 // Fingerprint on `name|kind|url` keeps us sensitive to the
1885 // visible surface (name changes, navigate-vs-action flips,
1886 // destination URL changes) while skipping `JSON.stringify` of
1887 // the entire payload — label/icon churn inside a single command
1888 // is rare and re-shipping on it is harmless noise vs. a hot
1889 // path allocation cost.
1890 var key = '';
1891 for ( var k = 0; k < list.length; k++ ) {
1892 var lc = list[ k ];
1893 key += ( lc && lc.name ? lc.name : '' ) + '|'
1894 + ( lc && lc.kind ? lc.kind : '' ) + '|'
1895 + ( lc && lc.url ? lc.url : '' ) + '\n';
1896 }
1897 if ( key === __wpdCommandsLastPayload ) {
1898 return;
1899 }
1900 __wpdCommandsLastPayload = key;
1901 try {
1902 window.parent.postMessage(
1903 { type: 'desktop-mode-commands-list', commands: list },
1904 __wpdCommandsOrigin
1905 );
1906 } catch ( _err ) {
1907 /* cross-origin parent (shouldn't happen for chromeless pages, but
1908 * don't let a throw break the bridge) */
1909 }
1910 }
1911
1912 function __wpdSchedulePost() {
1913 if ( __wpdCommandsDebounceId !== null ) return;
1914 __wpdCommandsDebounceId = window.setTimeout( function () {
1915 __wpdCommandsDebounceId = null;
1916 __wpdPostCommandsList();
1917 }, 60 );
1918 }
1919
1920 function __wpdSubscribeCommands() {
1921 __wpdCommandsSubscribed = true;
1922
1923 // If the React harvester is already running (focus left and
1924 // came back), the bucket still holds the latest merged list.
1925 // Reset the dedupe key so the next post actually ships, then
1926 // schedule it. The harvester itself won't re-fire its effects
1927 // just because the parent re-subscribed — React only reacts to
1928 // store changes, and the store hasn't changed. We have to
1929 // push from here.
1930 if ( __wpdReactMounted ) {
1931 __wpdCommandsLastPayload = '';
1932 __wpdSchedulePost();
1933 return;
1934 }
1935
1936 var attempts = 0;
1937 function tryBind() {
1938 if ( ! __wpdCommandsSubscribed ) return;
1939 if ( ! window.wp || ! window.wp.data || typeof window.wp.data.subscribe !== 'function' ) {
1940 if ( attempts++ < 40 ) {
1941 window.setTimeout( tryBind, 150 );
1942 }
1943 return;
1944 }
1945 // Mount the React harvester — tier 3 loaders are hooks and
1946 // need a legal render context to execute. On every re-render
1947 // the component's effect calls `__wpdSchedulePost` with the
1948 // fresh merged list, so we don't need a separate
1949 // `wp.data.subscribe` callback.
1950 __wpdMountReactHarvester();
1951 }
1952 tryBind();
1953 }
1954
1955 function __wpdUnsubscribeCommands() {
1956 __wpdCommandsSubscribed = false;
1957 __wpdCommandsLastPayload = '';
1958 if ( __wpdCommandsDebounceId !== null ) {
1959 try { window.clearTimeout( __wpdCommandsDebounceId ); } catch ( _err ) { /* swallow */ }
1960 __wpdCommandsDebounceId = null;
1961 }
1962 // Fully tear down the React harvester. Keeping it mounted in
1963 // the background wastes CPU: every store tick re-renders the
1964 // loader hooks, which rebuild the callback cache and post to
1965 // the parent (who drops the message because this window isn't
1966 // the subscribed one). On re-subscribe we remount from scratch.
1967 __wpdUnmountReactHarvester();
1968 }
1969
1970 function __wpdInvokeCommand( name ) {
1971 // Primary lookup — the React harvester's latest snapshot. This
1972 // covers loader-returned commands (Duplicate block, Transform
1973 // to, pattern commands) that never appear in the static
1974 // `getCommands()` list.
1975 var cb = __wpdCommandCallbacks[ name ];
1976 if ( typeof cb === 'function' ) {
1977 try {
1978 cb( { close: function () {} } );
1979 } catch ( _err ) {
1980 /* swallow — a plugin command callback that throws shouldn't break the bridge */
1981 }
1982 return;
1983 }
1984 // Fallback — statically registered commands that never passed
1985 // through the harvester (registered after the last render).
1986 if ( ! window.wp || ! window.wp.data ) {
1987 return;
1988 }
1989 var sel = null;
1990 try { sel = window.wp.data.select( 'core/commands' ); } catch ( _err ) { return; }
1991 if ( ! sel || typeof sel.getCommands !== 'function' ) return;
1992 var raw;
1993 try { raw = sel.getCommands(); } catch ( _err ) { return; }
1994 if ( ! raw ) return;
1995 for ( var i = 0; i < raw.length; i++ ) {
1996 if ( raw[ i ] && raw[ i ].name === name && typeof raw[ i ].callback === 'function' ) {
1997 try {
1998 raw[ i ].callback( { close: function () {} } );
1999 } catch ( _err ) {
2000 /* swallow — see note in primary path above */
2001 }
2002 return;
2003 }
2004 }
2005 }
2006
2007 // Attach the listener BEFORE the bridge-ready ping so a subscribe
2008 // posted synchronously in response is guaranteed to land.
2009 window.addEventListener( 'message', function ( e ) {
2010 if ( e.origin !== __wpdCommandsOrigin ) return;
2011 if ( ! e.data || typeof e.data.type !== 'string' ) return;
2012 if ( e.data.type === 'desktop-mode-commands-subscribe' ) {
2013 __wpdSubscribeCommands();
2014 } else if ( e.data.type === 'desktop-mode-commands-unsubscribe' ) {
2015 __wpdUnsubscribeCommands();
2016 } else if ( e.data.type === 'desktop-mode-commands-invoke' && typeof e.data.name === 'string' ) {
2017 __wpdInvokeCommand( e.data.name );
2018 }
2019 } );
2020
2021 // Handshake: tell the parent we're ready so it can (re)send any
2022 // subscribe that was dispatched before this listener attached.
2023 // Without this ping, a subscribe posted during iframe navigation
2024 // arrives at a context whose message listener isn't installed yet
2025 // and is silently dropped — the symptom is an empty palette even
2026 // though `wp.data.select('core/commands')` is perfectly happy.
2027 try {
2028 window.parent.postMessage(
2029 { type: 'desktop-mode-bridge-ready' },
2030 __wpdCommandsOrigin
2031 );
2032 } catch ( _err ) {
2033 /* parent gone or cross-origin — bridge handshake will retry on next load */
2034 }
2035
2036 /*
2037 * ` / Shift+` forwarder — window switcher.
2038 *
2039 * Bare backtick with no modifier. Must skip when focus is in a
2040 * text-entry element, otherwise typing ` into a block, a text
2041 * field, or TinyMCE would steal the keystroke. Non-text inputs
2042 * (checkbox, button, select) don't accept character input, so
2043 * cycling on those is fine.
2044 *
2045 * Same iframe-crossing rationale as the Cmd+K forwarder above:
2046 * native keydown doesn't reach the parent, so we postMessage.
2047 */
2048 document.addEventListener( 'keydown', function ( e ) {
2049 if ( e.ctrlKey || e.metaKey || e.altKey ) return;
2050 if ( e.code !== 'Backquote' ) return;
2051
2052 // IFRAME case catches Gutenberg: the block canvas is a nested
2053 // iframe, and Gutenberg re-dispatches cloned keydowns up to
2054 // this document for its shortcut system. Without this branch
2055 // typing ` in a block would cycle windows. Any other nested
2056 // iframe owning keyboard handling gets the same treatment.
2057 var el = document.activeElement;
2058 if ( el ) {
2059 var tag = el.tagName;
2060 if ( tag === 'IFRAME' ) return;
2061 if ( tag === 'TEXTAREA' ) return;
2062 if ( tag === 'INPUT' ) {
2063 var type = ( el.type || '' ).toLowerCase();
2064 var textTypes = [
2065 'text', 'search', 'url', 'email', 'password',
2066 'tel', 'number', 'date', 'datetime-local',
2067 'month', 'week', 'time'
2068 ];
2069 if ( textTypes.indexOf( type ) !== -1 ) return;
2070 }
2071 if ( el.isContentEditable ) return;
2072 }
2073
2074 e.preventDefault();
2075 e.stopImmediatePropagation();
2076
2077 try {
2078 window.parent.postMessage(
2079 {
2080 type: 'desktop-mode-window-switch',
2081 direction: e.shiftKey ? 'prev' : 'next'
2082 },
2083 window.location.origin
2084 );
2085 } catch ( err ) { /* cross-origin parent; swallow */ }
2086 }, true );
2087
2088 // Skip if the standalone iframe-bridge bundle already wired
2089 // screen-meta hoisting on this page. Two bridges racing to read
2090 // `aria-expanded` and reflect state would double-fire the
2091 // `desktop-mode-screen-meta-state` message and flicker the
2092 // title-bar buttons.
2093 if ( window.__desktopModeScreenMetaInstalled ) {
2094 return;
2095 }
2096 window.__desktopModeScreenMetaInstalled = true;
2097
2098 // Real screen options render form controls (column toggles, a
2099 // per-page input, custom settings). An empty wrap should not
2100 // surface a dead gear button.
2101 function hasScreenOptionsContent() {
2102 var wrap = document.getElementById( 'screen-options-wrap' );
2103 // WP always renders a nonce hidden input and an "Apply" submit
2104 // inside the wrap, so match only interactive option controls
2105 // (toggles, per-page, radios, selects) — never that always-
2106 // present scaffolding — or an empty panel reads as non-empty.
2107 return !! wrap && !! wrap.querySelector( 'input:not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]), select, textarea' );
2108 }
2109 // A help tab registered with empty content + no callback still
2110 // produces #contextual-help-link but an empty panel. Require some
2111 // non-whitespace tab/sidebar text before announcing the button.
2112 function hasHelpContent() {
2113 var wrap = document.getElementById( 'contextual-help-wrap' );
2114 if ( ! wrap ) {
2115 return false;
2116 }
2117 var panelEls = wrap.querySelectorAll( '.help-tab-content, .contextual-help-sidebar' );
2118 for ( var i = 0; i < panelEls.length; i++ ) {
2119 if ( ( panelEls[ i ].textContent || '' ).trim() !== '' ) {
2120 return true;
2121 }
2122 }
2123 return false;
2124 }
2125
2126 var links = document.getElementById( 'screen-meta-links' );
2127 var screenOptionsBtn = links ? document.getElementById( 'show-settings-link' ) : null;
2128 var helpBtn = links ? document.getElementById( 'contextual-help-link' ) : null;
2129 var panels = [];
2130 if ( screenOptionsBtn && hasScreenOptionsContent() ) {
2131 panels.push( 'screen-options' );
2132 }
2133 if ( helpBtn && hasHelpContent() ) {
2134 panels.push( 'help' );
2135 }
2136
2137 var origin = window.location.origin;
2138
2139 // ALWAYS announce — including an empty array — so the parent removes
2140 // stale gear/Help buttons when this page (e.g. after an in-place
2141 // same-slug navigation) has no screen meta. addScreenMetaButtons()
2142 // clears then repopulates, so an empty array removes everything.
2143 window.parent.postMessage( {
2144 type: 'desktop-mode-screen-meta',
2145 panels: panels
2146 }, origin );
2147
2148 if ( panels.length === 0 ) {
2149 return;
2150 }
2151
2152 function getOpenPanel() {
2153 if ( screenOptionsBtn && screenOptionsBtn.getAttribute( 'aria-expanded' ) === 'true' ) {
2154 return 'screen-options';
2155 }
2156 if ( helpBtn && helpBtn.getAttribute( 'aria-expanded' ) === 'true' ) {
2157 return 'help';
2158 }
2159 return null;
2160 }
2161
2162 function reportState() {
2163 window.parent.postMessage( {
2164 type: 'desktop-mode-screen-meta-state',
2165 open: getOpenPanel()
2166 }, origin );
2167 }
2168
2169 reportState();
2170
2171 var observer = new MutationObserver( reportState );
2172 if ( screenOptionsBtn ) {
2173 observer.observe( screenOptionsBtn, { attributes: true, attributeFilter: [ 'aria-expanded' ] } );
2174 }
2175 if ( helpBtn ) {
2176 observer.observe( helpBtn, { attributes: true, attributeFilter: [ 'aria-expanded' ] } );
2177 }
2178
2179 // WP's close() animates and shares #screen-meta between both panels,
2180 // so racing two animated clicks hides the panel that just opened.
2181 // Jump the other panel to its closed end state synchronously instead.
2182 function forceClose( button ) {
2183 if ( ! button || button.getAttribute( 'aria-expanded' ) !== 'true' ) {
2184 return;
2185 }
2186 var panelId = button.getAttribute( 'aria-controls' );
2187 var panel = panelId ? document.getElementById( panelId ) : null;
2188 if ( ! panel ) {
2189 return;
2190 }
2191 if ( window.jQuery ) {
2192 window.jQuery( panel ).stop( true, false );
2193 }
2194 panel.style.display = 'none';
2195 panel.classList.add( 'hidden' );
2196 if ( panel.parentNode instanceof HTMLElement ) {
2197 panel.parentNode.style.display = 'none';
2198 }
2199 button.classList.remove( 'screen-meta-active' );
2200 button.setAttribute( 'aria-expanded', 'false' );
2201 var toggles = document.querySelectorAll( '.screen-meta-toggle' );
2202 for ( var i = 0; i < toggles.length; i++ ) {
2203 toggles[ i ].style.visibility = '';
2204 }
2205 }
2206
2207 /* -----------------------------------------------------------------
2208 * Broadcast receiver — iframe side.
2209 *
2210 * The parent shell publishes broadcasts via
2211 * `wp.desktop.broadcast(topic, payload)` (see `src/broadcast.ts`).
2212 * It posts `{ type: 'desktop-mode-broadcast', topic, payload }` to
2213 * every open iframe. Here we re-dispatch that as a CustomEvent
2214 * on the iframe's own document so admin pages can subscribe with
2215 * plain `document.addEventListener( 'desktop-mode-broadcast', cb )`
2216 * — no extra script handle required.
2217 *
2218 * Iframe-side admin code can also publish UPSTREAM by posting
2219 * the same shape to `window.parent`; the parent's
2220 * `installBroadcastReceiver()` re-broadcasts to every other
2221 * iframe + native window.
2222 * ----------------------------------------------------------------- */
2223 window.addEventListener( 'message', function ( e ) {
2224 if ( e.origin !== origin ) {
2225 return;
2226 }
2227 if ( ! e.data || e.data.type !== 'desktop-mode-broadcast' ) {
2228 return;
2229 }
2230 try {
2231 document.dispatchEvent( new CustomEvent( 'desktop-mode-broadcast', {
2232 detail: { topic: e.data.topic, payload: e.data.payload }
2233 } ) );
2234 } catch ( _err ) { /* old browser without CustomEvent ctor — ignore */ }
2235 } );
2236
2237 /* -----------------------------------------------------------------
2238 * Soft-reload — iframe-side default handler.
2239 *
2240 * When a `desktop-mode.<post_type>.changed` broadcast fires AND the
2241 * current iframe is on a known list page for that post type, we
2242 * fetch the current URL and replace the iframe's `#wpbody-content`
2243 * in place. The user sees the new state of the list — restored
2244 * post appears, deleted media disappears — without the WP loading
2245 * spinner that `location.reload()` would show.
2246 *
2247 * Single-edit pages (`post.php`, `post-new.php`) are deliberately
2248 * NOT in the rule set: replacing their body would destroy any
2249 * unsaved Gutenberg/classic-editor state. Plugins that want
2250 * specific behaviour for those pages can subscribe to the same
2251 * topic on `document` and handle it themselves.
2252 *
2253 * The fetch carries a custom header so a later phase can serve a
2254 * minimal partial response if we want to optimise; for now WP
2255 * returns the full admin page and we just pluck the body.
2256 *
2257 * WP list-table JS uses event delegation on `document`/`body`,
2258 * which survives `replaceWith`. If a specific page breaks after
2259 * a swap (e.g. inline-edit double-binding), that page's plugin
2260 * should listen for `desktop-mode-soft-reloaded` and rebind.
2261 * ----------------------------------------------------------------- */
2262 var DESKTOP_MODE_SOFT_RELOAD_RULES = [
2263 {
2264 topic: 'desktop-mode.post.changed',
2265 match: function () {
2266 if ( ! _desktop_modeEndsWith( location.pathname, '/wp-admin/edit.php' ) ) return false;
2267 var t = new URLSearchParams( location.search ).get( 'post_type' );
2268 return t === null || t === 'post';
2269 }
2270 },
2271 {
2272 topic: 'desktop-mode.page.changed',
2273 match: function () {
2274 if ( ! _desktop_modeEndsWith( location.pathname, '/wp-admin/edit.php' ) ) return false;
2275 return new URLSearchParams( location.search ).get( 'post_type' ) === 'page';
2276 }
2277 },
2278 {
2279 topic: 'desktop-mode.attachment.changed',
2280 match: function () {
2281 return _desktop_modeEndsWith( location.pathname, '/wp-admin/upload.php' );
2282 }
2283 },
2284 {
2285 topic: 'desktop-mode.comment.changed',
2286 match: function () {
2287 return _desktop_modeEndsWith( location.pathname, '/wp-admin/edit-comments.php' );
2288 }
2289 }
2290 ];
2291
2292 function _desktop_modeEndsWith( s, suffix ) { return s.lastIndexOf( suffix ) === s.length - suffix.length; }
2293
2294 var _desktop_modeSoftReloadInFlight = false;
2295 var _desktop_modeSoftReloadQueued = false;
2296
2297 function _desktop_modeSoftReload() {
2298 if ( _desktop_modeSoftReloadInFlight ) {
2299 _desktop_modeSoftReloadQueued = true;
2300 return;
2301 }
2302 _desktop_modeSoftReloadInFlight = true;
2303 fetch( location.href, {
2304 credentials: 'same-origin',
2305 cache: 'no-cache',
2306 headers: { 'X-WP-Desktop-Soft-Reload': '1' }
2307 } ).then( function ( r ) {
2308 if ( ! r.ok ) throw new Error( 'soft-reload fetch failed: ' + r.status );
2309 return r.text();
2310 } ).then( function ( html ) {
2311 var doc = new DOMParser().parseFromString( html, 'text/html' );
2312 var fresh = doc.querySelector( '#wpbody-content' );
2313 var live = document.querySelector( '#wpbody-content' );
2314 if ( ! fresh || ! live ) {
2315 /* Markup we expected isn't there — admin pages we
2316 * don't recognise (or core changes the structure).
2317 * Don't reload; let the iframe stay as it is rather
2318 * than show a spinner the user told us not to. */
2319 return;
2320 }
2321 live.replaceWith( fresh );
2322 try {
2323 document.dispatchEvent( new CustomEvent( 'desktop-mode-soft-reloaded' ) );
2324 } catch ( _err ) {}
2325 /* Some WP scripts re-init on DOMContentLoaded only — let
2326 * pages opt-in to a re-init by listening to the event
2327 * above. We intentionally do NOT re-fire DOMContentLoaded;
2328 * that's almost always wrong (double-init of jQuery/WP). */
2329 } ).catch( function ( err ) {
2330 /* Network error — leave the iframe untouched. The user's
2331 * next manual interaction will refresh state, and the
2332 * next broadcast will retry. */
2333 if ( window.console && window.console.warn ) {
2334 window.console.warn( '[desktop-mode] soft-reload skipped:', err );
2335 }
2336 } ).then( function () {
2337 _desktop_modeSoftReloadInFlight = false;
2338 if ( _desktop_modeSoftReloadQueued ) {
2339 _desktop_modeSoftReloadQueued = false;
2340 _desktop_modeSoftReload();
2341 }
2342 } );
2343 }
2344
2345 document.addEventListener( 'desktop-mode-broadcast', function ( e ) {
2346 var detail = e.detail || {};
2347 var topic = detail.topic;
2348 if ( ! topic ) return;
2349 for ( var i = 0; i < DESKTOP_MODE_SOFT_RELOAD_RULES.length; i++ ) {
2350 var r = DESKTOP_MODE_SOFT_RELOAD_RULES[ i ];
2351 if ( r.topic === topic && r.match() ) {
2352 _desktop_modeSoftReload();
2353 return;
2354 }
2355 }
2356 } );
2357
2358 window.addEventListener( 'message', function( e ) {
2359 if ( e.origin !== origin ) {
2360 return;
2361 }
2362 if ( ! e.data || e.data.type !== 'desktop-mode-toggle-panel' ) {
2363 return;
2364 }
2365 var target = null;
2366 if ( e.data.panel === 'screen-options' && screenOptionsBtn ) {
2367 target = screenOptionsBtn;
2368 } else if ( e.data.panel === 'help' && helpBtn ) {
2369 target = helpBtn;
2370 }
2371 if ( ! target ) {
2372 return;
2373 }
2374 if ( target.getAttribute( 'aria-expanded' ) !== 'true' ) {
2375 var other = target === screenOptionsBtn ? helpBtn : screenOptionsBtn;
2376 forceClose( other );
2377 }
2378 target.click();
2379 } );
2380
2381 /* -----------------------------------------------------------------
2382 * Connection bridge — iframe side.
2383 *
2384 * Plugins call `wp.desktop.iframe.publish(topic, payload)` /
2385 * `subscribe(topic, cb)` / `onConnection(cb)` to talk to a parent-
2386 * side `wp.desktop.connect()` caller. The shell only routes;
2387 * topic semantics are plugin-defined.
2388 *
2389 * Connections are tracked locally so `onConnection` can fire when
2390 * the parent opens a new channel (typical use: start emitting
2391 * heavy events only after at least one consumer subscribed). Each
2392 * connection carries a topic-allowlist negotiated at handshake
2393 * time — wildcard ('*') subscribers see everything.
2394 * ----------------------------------------------------------------- */
2395 var _wpdConnections = {};
2396 var _wpdConnectionListeners = [];
2397 var _wpdSubs = {}; // topic → [cb, ...]
2398 var _wpdChannelSubs = {}; // channel → [cb, ...] (window-channel API)
2399 var _wpdParentOrigin = window.location.origin;
2400 var _wpdWindowId = null; // host window id, from the handshake
2401 var _wpdWindowIdWaiters = []; // pending whenWindowId() resolvers
2402
2403 /* Stash the host window's id (the parent's handshake carries
2404 * `targetWindowId`) and flush any `whenWindowId()` waiters. Same
2405 * contract as `assets/js/iframe-bridge.js`. */
2406 function _wpdSetWindowId( id ) {
2407 if ( ! id || _wpdWindowId === id ) {
2408 return;
2409 }
2410 _wpdWindowId = id;
2411 var waiters = _wpdWindowIdWaiters.splice( 0 );
2412 for ( var i = 0; i < waiters.length; i++ ) {
2413 try {
2414 waiters[ i ]( id );
2415 } catch ( _err ) { /* swallow */ }
2416 }
2417 }
2418
2419 function _wpdEmitToParent( connectionId, topic, payload ) {
2420 try {
2421 window.parent.postMessage( {
2422 type: 'desktop-mode-bridge-publish',
2423 connectionId: connectionId,
2424 topic: topic,
2425 payload: payload
2426 }, _wpdParentOrigin );
2427 } catch ( _err ) { /* parent gone */ }
2428 }
2429
2430 window.addEventListener( 'message', function ( ev ) {
2431 if ( ev.origin !== _wpdParentOrigin ) {
2432 return;
2433 }
2434 var data = ev && ev.data;
2435 if ( ! data || typeof data !== 'object' || typeof data.type !== 'string' ) {
2436 return;
2437 }
2438
2439 if ( data.type === 'desktop-mode-bridge-beforeunload-query' ) {
2440 var prevent = false;
2441 var msg = '';
2442
2443 function shimReturnValue( ev ) {
2444 Object.defineProperty( ev, 'returnValue', {
2445 get: function() { return this._returnValue || ''; },
2446 set: function( v ) { this._returnValue = v; }
2447 } );
2448 }
2449
2450 function checkPrevent( ev, result ) {
2451 var hasRes = typeof result === 'string' && result !== '';
2452 var hasRetVal = typeof ev.returnValue === 'string' && ev.returnValue !== '';
2453 if ( ev.defaultPrevented || hasRes || hasRetVal ) {
2454 prevent = true;
2455 if ( hasRes ) {
2456 msg = result;
2457 } else if ( hasRetVal ) {
2458 msg = ev.returnValue;
2459 }
2460 }
2461 }
2462
2463 var unloadEvent;
2464 try {
2465 unloadEvent = new Event( 'beforeunload', { cancelable: true } );
2466 } catch ( _err ) {
2467 unloadEvent = document.createEvent( 'Event' );
2468 unloadEvent.initEvent( 'beforeunload', false, true );
2469 }
2470 shimReturnValue( unloadEvent );
2471
2472 if ( typeof window.onbeforeunload === 'function' ) {
2473 var res = window.onbeforeunload( unloadEvent );
2474 checkPrevent( unloadEvent, res );
2475 }
2476 if ( ! prevent ) {
2477 var dispatchEvent;
2478 try {
2479 dispatchEvent = new Event( 'beforeunload', { cancelable: true } );
2480 } catch ( _err ) {
2481 dispatchEvent = document.createEvent( 'Event' );
2482 dispatchEvent.initEvent( 'beforeunload', false, true );
2483 }
2484 shimReturnValue( dispatchEvent );
2485 window.dispatchEvent( dispatchEvent );
2486 checkPrevent( dispatchEvent, null );
2487 }
2488
2489 try {
2490 window.parent.postMessage( {
2491 type: 'desktop-mode-bridge-beforeunload-response',
2492 prevent: prevent,
2493 message: msg
2494 }, _wpdParentOrigin );
2495 } catch ( _err ) { /* swallow */ }
2496 return;
2497 }
2498
2499 if ( data.type === 'desktop-mode-bridge-handshake' && typeof data.connectionId === 'string' ) {
2500 /* The parent's handshake carries the host window id —
2501 * stash it so `wp.desktop.iframe.windowId` and
2502 * `whenWindowId()` can serve callers that need to know
2503 * which native window opened this iframe. */
2504 if ( typeof data.targetWindowId === 'string' && data.targetWindowId !== '' ) {
2505 _wpdSetWindowId( data.targetWindowId );
2506 }
2507 if ( _wpdConnections[ data.connectionId ] ) {
2508 /* Re-handshake on iframe-ready re-arm — no-op besides
2509 * acking again so the parent can resume. */
2510 try {
2511 window.parent.postMessage( {
2512 type: 'desktop-mode-bridge-handshake-ack',
2513 connectionId: data.connectionId
2514 }, _wpdParentOrigin );
2515 } catch ( _err ) { /* swallow */ }
2516 return;
2517 }
2518 var conn = {
2519 id: data.connectionId,
2520 topics: Array.isArray( data.topics ) ? data.topics.slice() : []
2521 };
2522 _wpdConnections[ conn.id ] = conn;
2523 try {
2524 window.parent.postMessage( {
2525 type: 'desktop-mode-bridge-handshake-ack',
2526 connectionId: conn.id
2527 }, _wpdParentOrigin );
2528 } catch ( _err ) { /* swallow */ }
2529 for ( var i = 0; i < _wpdConnectionListeners.length; i++ ) {
2530 try {
2531 _wpdConnectionListeners[ i ]( {
2532 id: conn.id,
2533 topics: conn.topics.slice()
2534 } );
2535 } catch ( _err ) { /* swallow listener */ }
2536 }
2537 return;
2538 }
2539
2540 if ( data.type === 'desktop-mode-bridge-publish' && typeof data.topic === 'string' ) {
2541 var bucket = _wpdSubs[ data.topic ];
2542 if ( bucket ) {
2543 for ( var j = 0; j < bucket.length; j++ ) {
2544 try {
2545 bucket[ j ]( data.payload, { topic: data.topic, connectionId: data.connectionId } );
2546 } catch ( _err ) { /* swallow subscriber */ }
2547 }
2548 }
2549 var wildcard = _wpdSubs[ '*' ];
2550 if ( wildcard ) {
2551 for ( var k = 0; k < wildcard.length; k++ ) {
2552 try {
2553 wildcard[ k ]( data.payload, { topic: data.topic, connectionId: data.connectionId } );
2554 } catch ( _err ) { /* swallow */ }
2555 }
2556 }
2557 return;
2558 }
2559
2560 if ( data.type === 'desktop-mode-bridge-disconnect' && typeof data.connectionId === 'string' ) {
2561 delete _wpdConnections[ data.connectionId ];
2562 return;
2563 }
2564
2565 /* Unified window-channel delivery from the parent. Fires
2566 * every `wp.desktop.on( channel, cb )` subscriber for the
2567 * matching channel — same protocol as
2568 * `assets/js/iframe-bridge.js`. */
2569 if ( data.type === 'desktop-mode-window-send' && typeof data.channel === 'string' && data.channel !== '' ) {
2570 var meta = { channel: data.channel };
2571 var cBucket = _wpdChannelSubs[ data.channel ];
2572 if ( cBucket ) {
2573 var cBucketSnap = cBucket.slice();
2574 for ( var ci = 0; ci < cBucketSnap.length; ci++ ) {
2575 try {
2576 cBucketSnap[ ci ]( data.payload, meta );
2577 } catch ( _err ) { /* swallow */ }
2578 }
2579 }
2580 var cWildcard = _wpdChannelSubs[ '*' ];
2581 if ( cWildcard ) {
2582 var cWildcardSnap = cWildcard.slice();
2583 for ( var cw = 0; cw < cWildcardSnap.length; cw++ ) {
2584 try {
2585 cWildcardSnap[ cw ]( data.payload, meta );
2586 } catch ( _err ) { /* swallow */ }
2587 }
2588 }
2589 return;
2590 }
2591 } );
2592
2593 var iframeApi = {
2594 /**
2595 * Publish a payload under a topic. Sent to every connection
2596 * — typical case is one connection per parent caller, but
2597 * a debug console may have several at once.
2598 */
2599 publish: function ( topic, payload ) {
2600 if ( typeof topic !== 'string' || topic === '' ) {
2601 return;
2602 }
2603 var ids = Object.keys( _wpdConnections );
2604 for ( var i = 0; i < ids.length; i++ ) {
2605 _wpdEmitToParent( ids[ i ], topic, payload );
2606 }
2607 },
2608 /**
2609 * Subscribe to a topic. Returns an unsubscribe function.
2610 * Use `'*'` to receive every published payload (debugging).
2611 */
2612 subscribe: function ( topic, cb ) {
2613 if ( typeof topic !== 'string' || topic === '' || typeof cb !== 'function' ) {
2614 return function () {};
2615 }
2616 var bucket = _wpdSubs[ topic ];
2617 if ( ! bucket ) {
2618 bucket = [];
2619 _wpdSubs[ topic ] = bucket;
2620 }
2621 bucket.push( cb );
2622 return function () {
2623 var i = bucket.indexOf( cb );
2624 if ( i >= 0 ) {
2625 bucket.splice( i, 1 );
2626 }
2627 };
2628 },
2629 /**
2630 * Notified whenever a parent caller opens a connection. Use
2631 * to start emitting heavy publish events only when somebody
2632 * is listening.
2633 */
2634 onConnection: function ( cb ) {
2635 if ( typeof cb !== 'function' ) {
2636 return function () {};
2637 }
2638 _wpdConnectionListeners.push( cb );
2639 /* Replay current connections — late subscribers still
2640 * see who's already there. */
2641 var ids = Object.keys( _wpdConnections );
2642 for ( var i = 0; i < ids.length; i++ ) {
2643 try {
2644 cb( {
2645 id: _wpdConnections[ ids[ i ] ].id,
2646 topics: _wpdConnections[ ids[ i ] ].topics.slice()
2647 } );
2648 } catch ( _err ) { /* swallow */ }
2649 }
2650 return function () {
2651 var i = _wpdConnectionListeners.indexOf( cb );
2652 if ( i >= 0 ) {
2653 _wpdConnectionListeners.splice( i, 1 );
2654 }
2655 };
2656 },
2657 /**
2658 * Iframe-initiated connection request. See
2659 * `assets/js/iframe-bridge.js` — same shape, same protocol.
2660 */
2661 requestConnection: function ( opts ) {
2662 opts = opts || {};
2663 var topics = Array.isArray( opts.topics ) ? opts.topics.slice() : [];
2664 var requestId = 'wpdir-' + Math.random().toString( 36 ).slice( 2, 10 );
2665
2666 return new Promise( function ( resolve, reject ) {
2667 var settled = false;
2668 var timeoutMs = typeof opts.timeoutMs === 'number'
2669 ? opts.timeoutMs
2670 : 5000;
2671
2672 function settle( ok, value ) {
2673 if ( settled ) {
2674 return;
2675 }
2676 settled = true;
2677 window.removeEventListener( 'message', onAck );
2678 clearTimeout( timer );
2679 if ( ok ) {
2680 resolve( value );
2681 } else {
2682 reject( value );
2683 }
2684 }
2685
2686 function onAck( ev ) {
2687 if ( ev.origin !== _wpdParentOrigin ) {
2688 return;
2689 }
2690 var d = ev && ev.data;
2691 if (
2692 ! d ||
2693 typeof d !== 'object' ||
2694 d.type !== 'desktop-mode-bridge-connection-ack' ||
2695 d.requestId !== requestId
2696 ) {
2697 return;
2698 }
2699 if ( d.accepted ) {
2700 var summary = {
2701 id: typeof d.connectionId === 'string' ? d.connectionId : '',
2702 topics: topics.slice()
2703 };
2704 if ( typeof opts.onOpen === 'function' ) {
2705 try { opts.onOpen( summary ); } catch ( _err ) { /* swallow */ }
2706 }
2707 settle( true, summary );
2708 } else {
2709 settle( false, new Error( d.reason || 'rejected' ) );
2710 }
2711 }
2712 window.addEventListener( 'message', onAck );
2713
2714 var timer = setTimeout( function () {
2715 settle( false, new Error( 'timeout' ) );
2716 }, timeoutMs );
2717
2718 try {
2719 window.parent.postMessage( {
2720 type: 'desktop-mode-bridge-connection-request',
2721 requestId: requestId,
2722 topics: topics
2723 }, _wpdParentOrigin );
2724 } catch ( err ) {
2725 settle( false, err );
2726 }
2727 } );
2728 },
2729 /**
2730 * Window-chrome helpers. See `assets/js/iframe-bridge.js` —
2731 * same shape, same protocol. `setSlot` is HTML-only
2732 * (sandboxed via `textContent` on the parent side).
2733 */
2734 chrome: {
2735 setTheme: function ( tokens ) {
2736 try {
2737 window.parent.postMessage( {
2738 type: 'desktop-mode-chrome-theme',
2739 tokens: tokens || {}
2740 }, _wpdParentOrigin );
2741 } catch ( _err ) { /* parent gone */ }
2742 },
2743 setControls: function ( config ) {
2744 try {
2745 window.parent.postMessage( {
2746 type: 'desktop-mode-chrome-controls',
2747 config: config === undefined ? null : config
2748 }, _wpdParentOrigin );
2749 } catch ( _err ) { /* parent gone */ }
2750 },
2751 setSlot: function ( name, html ) {
2752 if ( typeof name !== 'string' || name === '' ) {
2753 return;
2754 }
2755 try {
2756 window.parent.postMessage( {
2757 type: 'desktop-mode-chrome-slot',
2758 slot: name,
2759 html: typeof html === 'string' ? html : ''
2760 }, _wpdParentOrigin );
2761 } catch ( _err ) { /* parent gone */ }
2762 }
2763 },
2764 /**
2765 * The id of the window the parent shell opened to host this
2766 * iframe. Populated by the first connection handshake (the
2767 * parent's handshake carries `targetWindowId`). `null` until
2768 * then.
2769 */
2770 get windowId() {
2771 return _wpdWindowId;
2772 },
2773 /**
2774 * Resolve once `windowId` is populated by the first handshake.
2775 * Resolves immediately if already known. Never rejects — guard
2776 * with `isParentReachable()` first.
2777 */
2778 whenWindowId: function () {
2779 if ( _wpdWindowId !== null ) {
2780 return Promise.resolve( _wpdWindowId );
2781 }
2782 return new Promise( function ( resolve ) {
2783 _wpdWindowIdWaiters.push( resolve );
2784 } );
2785 },
2786 /**
2787 * Whether the parent frame is same-origin and reachable. All
2788 * bridge messages hard-filter on origin — a cross-origin
2789 * parent silently drops everything we post. Use this predicate
2790 * to fail fast instead of debugging vanishing messages.
2791 */
2792 isParentReachable: function () {
2793 if ( ! window.parent || window.parent === window ) {
2794 return false;
2795 }
2796 try {
2797 /* Cross-origin parents throw on `.location.origin`
2798 * access; same-origin parents return a string we can
2799 * compare to our own origin. */
2800 return window.parent.location.origin === _wpdParentOrigin;
2801 } catch ( _err ) {
2802 return false;
2803 }
2804 }
2805 };
2806
2807 if ( ! window.wp ) { window.wp = {}; }
2808 if ( ! window.wp.desktop ) { window.wp.desktop = {}; }
2809 window.wp.desktop.iframe = iframeApi;
2810
2811 /* Unified window-channel API. Mirror of the equivalent block
2812 * in `assets/js/iframe-bridge.js` — keep both in sync. The
2813 * parent shell posts `desktop-mode-window-send` on
2814 * `Window.send( channel, payload )`; iframe-side handlers
2815 * register via `wp.desktop.on( channel, cb )`. Sending the
2816 * other way (`wp.desktop.send`) posts up to the parent where
2817 * `Window.on( channel, cb )` subscribers fire. */
2818 if ( typeof window.wp.desktop.send !== 'function' ) {
2819 window.wp.desktop.send = function ( channel, payload ) {
2820 if ( typeof channel !== 'string' || channel === '' ) {
2821 return;
2822 }
2823 try {
2824 window.parent.postMessage( {
2825 type: 'desktop-mode-window-publish',
2826 channel: channel,
2827 payload: payload
2828 }, _wpdParentOrigin );
2829 } catch ( _err ) { /* parent gone */ }
2830 };
2831 }
2832 if ( typeof window.wp.desktop.on !== 'function' ) {
2833 window.wp.desktop.on = function ( channel, cb ) {
2834 if ( typeof channel !== 'string' || channel === '' || typeof cb !== 'function' ) {
2835 return function () {};
2836 }
2837 var bucket = _wpdChannelSubs[ channel ];
2838 if ( ! bucket ) {
2839 bucket = [];
2840 _wpdChannelSubs[ channel ] = bucket;
2841 }
2842 bucket.push( cb );
2843 return function () {
2844 var i = bucket.indexOf( cb );
2845 if ( i >= 0 ) {
2846 bucket.splice( i, 1 );
2847 }
2848 };
2849 };
2850 }
2851
2852 /* -----------------------------------------------------------------
2853 * Stale-nonce recovery after wp-auth-check re-authentication.
2854 *
2855 * When the user's session expires while a chromeless window is
2856 * open, core's `wp-auth-check.js` shows its login iframe inside
2857 * this page. After re-auth the auth cookie is fresh — but every
2858 * per-page nonce cached in JS globals
2859 * (`_wpUpdatesSettings.ajax_nonce`, `commonL10n.nonce`, Gutenberg's
2860 * `wpApiSettings.nonce`, etc.) was minted under the OLD nonce-tick
2861 * and is now rejected by `check_ajax_referer`. WP reports that as
2862 * "Cookie check failed" on the next plugin Install / Activate /
2863 * Update click, which is misleading: the cookie is fine; the
2864 * nonce is stale.
2865 *
2866 * Fix: watch jQuery's `heartbeat-tick`. If we ever see
2867 * `wp-auth-check: false` (the modal trigger) and then later see
2868 * the same field flip back to `true`, the user re-authed
2869 * mid-session and every cached nonce in this iframe is stale —
2870 * reload so they regenerate from the fresh session.
2871 *
2872 * Per-iframe scope is intentional: each chromeless iframe carries
2873 * its own jQuery + heartbeat stack and its own nonce caches.
2874 * Siblings recover on their own next tick. We don't broadcast a
2875 * reload to peers because the parent shell may still be running
2876 * core's confirm() prompts and we don't want to surprise-reload
2877 * windows with unsaved state.
2878 *
2879 * If jQuery never loads on this page (rare — most admin screens
2880 * pull it for heartbeat already), this block is a no-op.
2881 * ----------------------------------------------------------------- */
2882 ( function _wpdInstallAuthCheckRecovery() {
2883 var attached = false;
2884 var sawLoggedOut = false;
2885 function attach() {
2886 if ( attached || ! window.jQuery ) {
2887 return;
2888 }
2889 attached = true;
2890 window.jQuery( document ).on( 'heartbeat-tick.wpdAuthRecover', function ( ev, data ) {
2891 if ( ! data || typeof data !== 'object' || ! ( 'wp-auth-check' in data ) ) {
2892 return;
2893 }
2894 if ( data[ 'wp-auth-check' ] === false ) {
2895 sawLoggedOut = true;
2896 return;
2897 }
2898 if ( sawLoggedOut && data[ 'wp-auth-check' ] === true ) {
2899 sawLoggedOut = false;
2900 // Tell the parent shell BEFORE we reload so it
2901 // doesn't have to wait for its own heartbeat
2902 // tick (up to 60s on an idle shell) to discover
2903 // the cookie is fresh. Parent runs its full
2904 // recovery path on receipt — overlay teardown,
2905 // iframe reload sweep, then a hard reload.
2906 try {
2907 if ( window.parent && window.parent !== window ) {
2908 window.parent.postMessage(
2909 { type: 'desktop-mode-reauth-detected' },
2910 window.location.origin
2911 );
2912 }
2913 } catch ( _err ) { /* parent gone */ }
2914 try { window.location.reload(); } catch ( _err ) { /* swallow */ }
2915 }
2916 } );
2917 }
2918 attach();
2919 if ( document.readyState === 'loading' ) {
2920 document.addEventListener( 'DOMContentLoaded', attach, { once: true } );
2921 }
2922 window.addEventListener( 'load', attach, { once: true } );
2923 } )();
2924
2925 /*
2926 * Bridge-ready signal. Every listener installed by this script
2927 * is now wired; let the parent shell know so it can fire
2928 * `HOOKS.IFRAME_READY` and re-arm any connection handshakes
2929 * (`src/connection/index.ts#onIframeReady`) that arrived before
2930 * we were listening. Without this, every consumer of
2931 * `HOOKS.IFRAME_READY` (devtools replay, connection rearm)
2932 * stays silent for the lifetime of the iframe — documented
2933 * surface that never actually fires.
2934 *
2935 * Posted to the parent's own origin only. Wrapped in try/catch
2936 * because cross-origin parents (top-level admin opened outside
2937 * the shell) would throw on the postMessage and we don't want a
2938 * single failed dispatch to wedge anything else above.
2939 */
2940 try {
2941 if ( window.parent && window.parent !== window ) {
2942 window.parent.postMessage(
2943 { type: 'desktop-mode-ready' },
2944 window.location.origin
2945 );
2946 }
2947 } catch ( _err ) { /* parent gone or cross-origin */ }
2948 } )();
2949 JS;
2950
2951 // On pages that don't carry a full payload, ship the lightweight
2952 // menu signature so the shell can detect an off-allowlist menu
2953 // change (e.g. a CPT registered via a settings tool) and refresh
2954 // only then. The full payload already embeds its own `menuSig`, so
2955 // there's no point recomputing it when one is being sent. GH#325.
2956 $menu_sig_json = 'null';
2957 if ( 'null' === $menu_payload_json ) {
2958 $menu_sig = desktop_mode_menu_signature();
2959 if ( '' !== $menu_sig ) {
2960 $encoded_sig = wp_json_encode( $menu_sig );
2961 if ( false !== $encoded_sig ) {
2962 $menu_sig_json = $encoded_sig;
2963 }
2964 }
2965 }
2966
2967 // Substitute the server-built menu payload into the bridge
2968 // script. `wp_json_encode` guarantees safe JSON output — no need
2969 // for an additional escape pass. When the page isn't on our
2970 // menu-altering allowlist the placeholder resolves to `null` and
2971 // the bridge skips the postMessage.
2972 $js = str_replace( '/*__DESKTOP_MODE_MENU_PAYLOAD__*/', $menu_payload_json, $js );
2973 $js = str_replace( '/*__DESKTOP_MODE_MENU_SIG__*/', $menu_sig_json, $js );
2974 $js = str_replace( '/*__DESKTOP_MODE_CONTENT_IDENTITY__*/', $content_identity_json, $js );
2975
2976 wp_print_inline_script_tag( $js );
2977 }
2978 add_action( 'admin_footer', 'desktop_mode_chromeless_bridge_script' );
2979