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

2,624 lines 93.5 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.18.4
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 // Emit via wp_print_inline_script_tag so CSP nonces and `<script>`
301 // attribute hygiene go through Core rather than being hand-rolled.
302 $js = <<<'JS'
303 //# sourceURL=desktop-mode-chromeless-bridge.js
304 ( function() {
305 // Escape hatch: a chromeless page is only meant to live inside a
306 // desktop-mode window iframe. If the top window IS this page, the
307 // user ended up here directly — either bookmarked it, followed a
308 // stale link, or got stranded by a bad portal redirect. Without
309 // an admin bar there's no toggle to turn desktop mode off, so
310 // strip the chromeless flag and reload as classic admin. That
311 // puts the admin bar back and lets the user decide what to do.
312 if ( ! window.parent || window.parent === window ) {
313 try {
314 var here = new URL( window.location.href );
315 if ( here.searchParams.has( 'desktop_mode_chromeless' ) ) {
316 here.searchParams.delete( 'desktop_mode_chromeless' );
317 here.searchParams.delete( 'desktop_mode_portal' );
318 window.location.replace( here.toString() );
319 }
320 } catch ( err ) {
321 /* URL parse failure — let the broken state stand rather than
322 * navigate somewhere worse. */
323 }
324 return;
325 }
326
327 /*
328 * Observability — iframe error + network capture.
329 *
330 * Everything admin-interesting (REST failures from Gutenberg,
331 * admin-ajax 500s, plugin console warnings) fires INSIDE the
332 * iframe whose parent is the desktop shell. Without relaying
333 * those events to the shell, monitor / debug widgets would only
334 * ever see the shell's own errors — the smallest, least-
335 * interesting surface in the whole admin.
336 *
337 * Two listeners and two wrappers land here:
338 *
339 * - `error` + `unhandledrejection` on window → postMessage
340 * `desktop-mode-iframe-error`. Parent dispatches `HOOKS.
341 * IFRAME_ERROR`.
342 * - `fetch` + `XMLHttpRequest` are wrapped so every completed
343 * request (including failures) posts
344 * `desktop-mode-iframe-network` with `{ method, url, status,
345 * duration, failed }`. Parent dispatches `HOOKS.
346 * IFRAME_NETWORK_COMPLETED`.
347 *
348 * Privacy: request / response bodies are NEVER captured — only
349 * method, URL, status, duration. Monitor widgets that want the
350 * full payload must ship their own deeper wrapper (at which
351 * point they own the consent conversation).
352 */
353 try {
354 window.addEventListener( 'error', function ( e ) {
355 try {
356 window.parent.postMessage( {
357 type: 'desktop-mode-iframe-error',
358 kind: 'error',
359 message: e && e.message ? String( e.message ) : '',
360 filename: e && e.filename ? String( e.filename ) : null,
361 lineno: e && typeof e.lineno === 'number' ? e.lineno : null,
362 colno: e && typeof e.colno === 'number' ? e.colno : null,
363 stack: e && e.error && e.error.stack ? String( e.error.stack ) : null
364 }, window.location.origin );
365 } catch ( _err ) { /* swallow: don't let the relay compound the error */ }
366 } );
367
368 window.addEventListener( 'unhandledrejection', function ( e ) {
369 try {
370 var reason = e && 'reason' in e ? e.reason : null;
371 var message = '';
372 var stack = null;
373 if ( reason instanceof Error ) {
374 message = reason.message;
375 stack = reason.stack || null;
376 } else if ( reason !== null && reason !== undefined ) {
377 try { message = String( reason ); } catch ( _s ) { message = '[unstringifiable]'; }
378 }
379 window.parent.postMessage( {
380 type: 'desktop-mode-iframe-error',
381 kind: 'unhandledrejection',
382 message: message,
383 filename: null,
384 lineno: null,
385 colno: null,
386 stack: stack
387 }, window.location.origin );
388 } catch ( _err ) { /* swallow */ }
389 } );
390
391 // Devtools instrumentation slot — populated by
392 // `desktop-mode-instrument-set` messages from the parent shell.
393 // Mutable: parent overwrites the whole object on every change
394 // (header add/remove, observe toggle).
395 //
396 // Headers: { name: 'value' } — already pre-merged by the parent
397 // (RFC 7230 §3.2.2 join applied there).
398 // Observe: when true, network reports include request +
399 // response headers; otherwise only the privacy-conscious
400 // summary travels parent-bound.
401 window.__wpdInstrument = window.__wpdInstrument || { headers: {}, observe: false };
402 try {
403 window.addEventListener( 'message', function ( ev ) {
404 if ( ev.origin !== window.location.origin || ev.source !== window.parent ) {
405 return;
406 }
407 var d = ev && ev.data;
408 if ( ! d || typeof d !== 'object' || d.type !== 'desktop-mode-instrument-set' ) {
409 return;
410 }
411 window.__wpdInstrument = {
412 headers: d.headers && typeof d.headers === 'object' ? d.headers : {},
413 observe: !! d.observe
414 };
415 } );
416 } catch ( _err ) { /* swallow — instrumentation is best-effort */ }
417
418 var wpdReportNetwork = function ( method, url, status, duration, failed, extra ) {
419 try {
420 var msg = {
421 type: 'desktop-mode-iframe-network',
422 method: String( method || 'GET' ).toUpperCase(),
423 url: String( url || '' ),
424 status: typeof status === 'number' ? status : 0,
425 duration: typeof duration === 'number' ? duration : 0,
426 failed: !! failed
427 };
428 if ( extra && window.__wpdInstrument && window.__wpdInstrument.observe ) {
429 if ( extra.requestHeaders ) {
430 msg.requestHeaders = extra.requestHeaders;
431 }
432 if ( extra.responseHeaders ) {
433 msg.responseHeaders = extra.responseHeaders;
434 }
435 }
436 window.parent.postMessage( msg, window.location.origin );
437 } catch ( _err ) { /* swallow */ }
438 };
439
440 // Helper — when an admin-side request returns 401/403 the
441 // session is most likely toast. Don't wait up to 60s for the
442 // next heartbeat tick to surface core's auth-check modal —
443 // force an immediate tick. `wp.heartbeat.connectNow()` is
444 // safe to call repeatedly; we still debounce to avoid storms
445 // when many requests fail at once. Same-origin gate keeps us
446 // out of third-party 403s. The URL gate avoids looping on
447 // heartbeat itself (heartbeat shouldn't 403 — but if it does
448 // the recursive connectNow would not help anyway).
449 var wpdAuthCheckCooldownUntil = 0;
450 var wpdMaybeForceAuthCheck = function ( status, url ) {
451 if ( status !== 401 && status !== 403 ) {
452 return;
453 }
454 var urlStr = String( url || '' );
455 if ( ! urlStr ) {
456 return;
457 }
458 // Cross-origin URLs aren't ours to interpret.
459 try {
460 var resolved = new URL( urlStr, window.location.href );
461 if ( resolved.origin !== window.location.origin ) {
462 return;
463 }
464 // Skip heartbeat to avoid recursion. Skip wp-login
465 // because the login iframe itself returns 4xx during
466 // the auth handshake and we don't want to retrigger.
467 if (
468 resolved.pathname.indexOf( '/wp-admin/admin-ajax.php' ) !== -1
469 && /(?:^|&|\?)action=heartbeat(?:&|$)/.test( resolved.search )
470 ) {
471 return;
472 }
473 if ( resolved.pathname.indexOf( '/wp-login.php' ) !== -1 ) {
474 return;
475 }
476 } catch ( _err ) {
477 return;
478 }
479 var now = Date.now();
480 if ( now < wpdAuthCheckCooldownUntil ) {
481 return;
482 }
483 wpdAuthCheckCooldownUntil = now + 5000;
484 try {
485 if (
486 window.wp
487 && window.wp.heartbeat
488 && typeof window.wp.heartbeat.connectNow === 'function'
489 ) {
490 window.wp.heartbeat.connectNow();
491 }
492 } catch ( _err ) { /* swallow */ }
493 };
494
495 // Helper — convert an arbitrary `init.headers` shape into a
496 // plain `{ name: value }` map so the instrument layer can
497 // merge contributed headers without caring whether the caller
498 // passed a Headers, an array of pairs, or a plain object.
499 var wpdHeadersToObject = function ( h ) {
500 var out = {};
501 if ( ! h ) {
502 return out;
503 }
504 if ( typeof Headers !== 'undefined' && h instanceof Headers ) {
505 try {
506 h.forEach( function ( v, k ) { out[ k ] = v; } );
507 } catch ( _e ) { /* swallow */ }
508 return out;
509 }
510 if ( Array.isArray( h ) ) {
511 for ( var i = 0; i < h.length; i++ ) {
512 if ( h[ i ] && h[ i ].length >= 2 ) {
513 out[ h[ i ][ 0 ] ] = h[ i ][ 1 ];
514 }
515 }
516 return out;
517 }
518 if ( typeof h === 'object' ) {
519 for ( var k in h ) {
520 if ( Object.prototype.hasOwnProperty.call( h, k ) ) {
521 out[ k ] = h[ k ];
522 }
523 }
524 }
525 return out;
526 };
527
528 // Helper — snapshot the contributed-header set at request time.
529 // Header values can theoretically come and go between requests
530 // (parent ref-counts contributions) so we read fresh on every
531 // call rather than caching at wrap time.
532 var wpdContributedHeaders = function () {
533 var inst = window.__wpdInstrument || {};
534 var headers = inst.headers || {};
535 var out = {};
536 for ( var k in headers ) {
537 if ( Object.prototype.hasOwnProperty.call( headers, k ) && typeof headers[ k ] === 'string' ) {
538 out[ k ] = headers[ k ];
539 }
540 }
541 return out;
542 };
543
544 // Wrap fetch. Called AFTER `admin_footer` runs — plugin code
545 // using fetch during synchronous page boot (rare in wp-admin)
546 // bypasses this, but lazy calls (the common case) are captured.
547 //
548 // Two layers of behavior:
549 //
550 // - Always: timing + status reporting (the original
551 // observability contract).
552 // - When `__wpdInstrument.headers` is non-empty: merge those
553 // headers into the request before dispatch so devtools can
554 // tag every outgoing call without each plugin reinventing
555 // a fetch wrapper.
556 // - When `__wpdInstrument.observe`: also relay request +
557 // response headers in the parent-bound network message.
558 if ( typeof window.fetch === 'function' ) {
559 var wpdOrigFetch = window.fetch;
560 window.fetch = function ( input, init ) {
561 var start = ( typeof performance !== 'undefined' && performance.now )
562 ? performance.now()
563 : Date.now();
564 var method = 'GET';
565 var url = '';
566 if ( typeof input === 'string' ) {
567 url = input;
568 if ( init && typeof init.method === 'string' ) {
569 method = init.method;
570 }
571 } else if ( input && typeof input === 'object' ) {
572 url = input.url || '';
573 method = ( input.method || ( init && init.method ) || 'GET' );
574 }
575
576 // Header contribution + capture. Build a single
577 // `Headers` instance so contributed values overwrite /
578 // stack predictably regardless of the caller's input
579 // shape, then re-attach to a cloned init.
580 var contributed = wpdContributedHeaders();
581 var observe = window.__wpdInstrument && window.__wpdInstrument.observe;
582 var requestHeaders = null;
583 var hasContributed = false;
584 for ( var ck in contributed ) {
585 if ( Object.prototype.hasOwnProperty.call( contributed, ck ) ) {
586 hasContributed = true;
587 break;
588 }
589 }
590 if ( hasContributed || observe ) {
591 var existing = wpdHeadersToObject( init && init.headers );
592 if ( input && typeof input === 'object' && input.headers ) {
593 var fromReq = wpdHeadersToObject( input.headers );
594 for ( var rk in fromReq ) {
595 if ( Object.prototype.hasOwnProperty.call( fromReq, rk ) && ! ( rk in existing ) ) {
596 existing[ rk ] = fromReq[ rk ];
597 }
598 }
599 }
600 for ( var ck2 in contributed ) {
601 if ( Object.prototype.hasOwnProperty.call( contributed, ck2 ) ) {
602 existing[ ck2 ] = contributed[ ck2 ];
603 }
604 }
605 if ( hasContributed ) {
606 init = init ? Object.assign( {}, init ) : {};
607 init.headers = existing;
608 arguments[ 1 ] = init;
609 }
610 if ( observe ) {
611 requestHeaders = existing;
612 }
613 }
614
615 var promise;
616 try {
617 promise = wpdOrigFetch.apply( this, arguments );
618 } catch ( sync ) {
619 wpdReportNetwork( method, url, 0, 0, true, requestHeaders ? { requestHeaders: requestHeaders } : null );
620 throw sync;
621 }
622 return promise.then(
623 function ( res ) {
624 var dur = ( ( typeof performance !== 'undefined' && performance.now )
625 ? performance.now()
626 : Date.now() ) - start;
627 var extra = null;
628 if ( requestHeaders ) {
629 extra = { requestHeaders: requestHeaders };
630 try {
631 var rh = {};
632 if ( res && res.headers && typeof res.headers.forEach === 'function' ) {
633 res.headers.forEach( function ( v, k ) { rh[ k ] = v; } );
634 }
635 extra.responseHeaders = rh;
636 } catch ( _hErr ) { /* swallow */ }
637 }
638 wpdReportNetwork( method, url, res.status, Math.round( dur ), ! res.ok, extra );
639 wpdMaybeForceAuthCheck( res.status, url );
640 return res;
641 },
642 function ( err ) {
643 var dur = ( ( typeof performance !== 'undefined' && performance.now )
644 ? performance.now()
645 : Date.now() ) - start;
646 wpdReportNetwork( method, url, 0, Math.round( dur ), true, requestHeaders ? { requestHeaders: requestHeaders } : null );
647 throw err;
648 }
649 );
650 };
651 }
652
653 // Wrap XHR — admin-ajax runs through jQuery which runs through
654 // XHR, so fetch-only instrumentation would miss most of the
655 // legacy admin surface. Record method + URL on open; fire on
656 // loadend regardless of success / failure.
657 //
658 // Header contribution layer: `setRequestHeader` after open() but
659 // before send() — that's the only window the spec allows. The
660 // caller's own headers are tracked so observation can include
661 // them alongside the contributed ones.
662 if ( typeof XMLHttpRequest !== 'undefined' ) {
663 var wpdOrigOpen = XMLHttpRequest.prototype.open;
664 var wpdOrigSend = XMLHttpRequest.prototype.send;
665 var wpdOrigSetHeader = XMLHttpRequest.prototype.setRequestHeader;
666 XMLHttpRequest.prototype.open = function ( method, url ) {
667 try {
668 this.__wpdMethod = method;
669 this.__wpdUrl = url;
670 this.__wpdReqHeaders = {};
671 } catch ( _err ) { /* frozen instance — skip */ }
672 return wpdOrigOpen.apply( this, arguments );
673 };
674 XMLHttpRequest.prototype.setRequestHeader = function ( name, value ) {
675 try {
676 if ( ! this.__wpdReqHeaders ) {
677 this.__wpdReqHeaders = {};
678 }
679 this.__wpdReqHeaders[ name ] = value;
680 } catch ( _err ) { /* swallow */ }
681 return wpdOrigSetHeader.apply( this, arguments );
682 };
683 XMLHttpRequest.prototype.send = function () {
684 var xhr = this;
685 var start = ( typeof performance !== 'undefined' && performance.now )
686 ? performance.now()
687 : Date.now();
688
689 // Apply contributed headers right before send. Doing it
690 // here rather than in open() means contributions added
691 // after open() (e.g. in async-built request flows) still
692 // land on the wire.
693 var contributed = wpdContributedHeaders();
694 var observe = window.__wpdInstrument && window.__wpdInstrument.observe;
695 for ( var hk in contributed ) {
696 if ( Object.prototype.hasOwnProperty.call( contributed, hk ) ) {
697 try {
698 wpdOrigSetHeader.call( xhr, hk, contributed[ hk ] );
699 if ( ! xhr.__wpdReqHeaders ) {
700 xhr.__wpdReqHeaders = {};
701 }
702 xhr.__wpdReqHeaders[ hk ] = contributed[ hk ];
703 } catch ( _hErr ) { /* `setRequestHeader` rejects forbidden names — skip */ }
704 }
705 }
706
707 var fire = function () {
708 var dur = ( ( typeof performance !== 'undefined' && performance.now )
709 ? performance.now()
710 : Date.now() ) - start;
711 var extra = null;
712 if ( observe ) {
713 extra = {
714 requestHeaders: xhr.__wpdReqHeaders || {}
715 };
716 try {
717 var raw = xhr.getAllResponseHeaders ? xhr.getAllResponseHeaders() : '';
718 var resHeaders = {};
719 if ( raw && typeof raw === 'string' ) {
720 var lines = raw.trim().split( /[\r\n]+/ );
721 for ( var li = 0; li < lines.length; li++ ) {
722 var idx = lines[ li ].indexOf( ':' );
723 if ( idx > 0 ) {
724 resHeaders[ lines[ li ].slice( 0, idx ).trim() ] = lines[ li ].slice( idx + 1 ).trim();
725 }
726 }
727 }
728 extra.responseHeaders = resHeaders;
729 } catch ( _rErr ) { /* swallow */ }
730 }
731 wpdReportNetwork(
732 xhr.__wpdMethod,
733 xhr.__wpdUrl,
734 xhr.status,
735 Math.round( dur ),
736 xhr.status === 0 || xhr.status >= 400,
737 extra
738 );
739 wpdMaybeForceAuthCheck( xhr.status, xhr.__wpdUrl );
740 };
741 try {
742 xhr.addEventListener( 'loadend', fire );
743 } catch ( _err ) { /* swallow */ }
744 return wpdOrigSend.apply( this, arguments );
745 };
746 }
747
748 // Wrap sendBeacon — used by analytics + telemetry. The Beacon
749 // API doesn't accept headers (the entire point of beacons is
750 // minimal payload + best-effort delivery). When devtools have
751 // contributed headers we silently fall back to fetch with
752 // `keepalive: true`, which is the closest semantic match —
753 // guaranteed POST + same fire-and-forget intent + custom headers
754 // allowed. Without contributions we just relay the call.
755 if ( typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function' ) {
756 var wpdOrigBeacon = navigator.sendBeacon.bind( navigator );
757 navigator.sendBeacon = function ( url, data ) {
758 var contributed = wpdContributedHeaders();
759 var hasContributed = false;
760 for ( var ck in contributed ) {
761 if ( Object.prototype.hasOwnProperty.call( contributed, ck ) ) {
762 hasContributed = true;
763 break;
764 }
765 }
766 var start = ( typeof performance !== 'undefined' && performance.now )
767 ? performance.now()
768 : Date.now();
769 if ( ! hasContributed ) {
770 var ok = false;
771 try { ok = !! wpdOrigBeacon( url, data ); } catch ( _e ) { ok = false; }
772 wpdReportNetwork( 'POST', url, ok ? 200 : 0, 0, ! ok );
773 return ok;
774 }
775 try {
776 var observe = window.__wpdInstrument && window.__wpdInstrument.observe;
777 var headers = {};
778 for ( var hk2 in contributed ) {
779 if ( Object.prototype.hasOwnProperty.call( contributed, hk2 ) ) {
780 headers[ hk2 ] = contributed[ hk2 ];
781 }
782 }
783 window.fetch( url, {
784 method: 'POST',
785 body: data,
786 keepalive: true,
787 credentials: 'same-origin',
788 headers: headers
789 } ).then(
790 function ( res ) {
791 var dur = ( ( typeof performance !== 'undefined' && performance.now )
792 ? performance.now()
793 : Date.now() ) - start;
794 wpdReportNetwork( 'POST', url, res.status, Math.round( dur ), ! res.ok, observe ? { requestHeaders: headers } : null );
795 },
796 function () {
797 var dur = ( ( typeof performance !== 'undefined' && performance.now )
798 ? performance.now()
799 : Date.now() ) - start;
800 wpdReportNetwork( 'POST', url, 0, Math.round( dur ), true, observe ? { requestHeaders: headers } : null );
801 }
802 );
803 return true;
804 } catch ( _bErr ) {
805 return false;
806 }
807 };
808 }
809 } catch ( _err ) {
810 /* Whole observability block is best-effort. If something in
811 * the environment disagrees (frozen prototypes, CSP blocking
812 * postMessage, etc.) we don't want to tank the rest of the
813 * chromeless bridge. */
814 }
815
816 /*
817 * Menu-changed signal.
818 *
819 * The shell's dock is built from `$menu` at page-load time and
820 * then frozen — the iframe reload that follows plugin
821 * activation / deactivation / installation doesn't tell the
822 * parent the admin menu just mutated. This handler fires inside
823 * the iframe that JUST LOADED plugins.php (or a sibling menu-
824 * affecting page) and hands the parent a fresh payload the PHP
825 * side built server-side from the live $menu globals.
826 *
827 * Why not a REST roundtrip: plugins commonly gate their
828 * `admin_menu` registration on `is_admin()` evaluated AT PLUGIN
829 * LOAD. REST requests don't define `WP_ADMIN` at plugin-load
830 * time, so those plugins never register and a REST-context
831 * bootstrap can't retroactively make them. By capturing the
832 * payload here, inside a real admin context, we get the
833 * authoritative post-activation state that any REST endpoint
834 * would miss.
835 *
836 * Covered pages:
837 * - plugins.php — activate, deactivate, bulk, delete.
838 * - plugin-install.php — install new, install-and-activate.
839 * - update.php — update / install handler (install-
840 * plugin + upload-plugin actions).
841 * - themes.php — theme switch (rare but can add menus).
842 */
843 var __DESKTOP_MODE_MENU_PAYLOAD__ = /*__DESKTOP_MODE_MENU_PAYLOAD__*/;
844 /*
845 * Icon harvest from the iframe's authoritative #adminmenu.
846 *
847 * The server-side payload only knows what the plugin set on
848 * $menu[$i][6]. Plugins that register their icon with 'none' /
849 * 'div' and paint it via a CSS rule on `#adminmenu .menu-icon-X`
850 * (All in One WP Migration, plus a long tail of older plugins)
851 * end up serialized with the gear fallback.
852 *
853 * On a regular page load the parent shell's resolveIcon() falls
854 * back to the parent's hidden #adminmenu DOM and reads the icon
855 * from there — but on a live activation the parent's #adminmenu
856 * is stale (it was rendered before the plugin existed). This
857 * iframe just rendered plugins.php in real admin context, so its
858 * own #adminmenu DOM IS authoritative; harvest each menu item's
859 * resolved icon here and patch the dockItems before postMessage.
860 */
861 try {
862 if (
863 __DESKTOP_MODE_MENU_PAYLOAD__
864 && Array.isArray( __DESKTOP_MODE_MENU_PAYLOAD__.dockItems )
865 ) {
866 var __wpdAdminMenu = document.getElementById( 'adminmenu' );
867 if ( __wpdAdminMenu ) {
868 var __wpdHarvest = {};
869 var __wpdLinks = __wpdAdminMenu.querySelectorAll( 'li.menu-top > a' );
870 for ( var __wpdLi = 0; __wpdLi < __wpdLinks.length; __wpdLi++ ) {
871 var __wpdLink = __wpdLinks[ __wpdLi ];
872 var __wpdKey;
873 try {
874 var __wpdU = new URL( __wpdLink.href || '', window.location.href );
875 __wpdKey = ( __wpdU.pathname.split( '/' ).pop() || '' ) + __wpdU.search;
876 } catch ( __wpdE1 ) { continue; }
877 if ( ! __wpdKey ) { continue; }
878 var __wpdImgWrap = __wpdLink.querySelector( '.wp-menu-image' );
879 if ( ! __wpdImgWrap ) { continue; }
880
881 /* (a) <img src> nested inside .wp-menu-image */
882 var __wpdImg = __wpdImgWrap.querySelector( 'img' );
883 if ( __wpdImg && __wpdImg.src ) {
884 __wpdHarvest[ __wpdKey ] = __wpdImg.src;
885 continue;
886 }
887
888 /* (b) dashicon class on the wrap div itself */
889 var __wpdDash = ( __wpdImgWrap.className || '' ).match( /\bdashicons-[\w-]+\b/ );
890 if (
891 __wpdDash
892 && __wpdDash[ 0 ] !== 'dashicons-before'
893 && __wpdDash[ 0 ] !== 'dashicons-admin-generic'
894 ) {
895 __wpdHarvest[ __wpdKey ] = __wpdDash[ 0 ];
896 continue;
897 }
898
899 /* (c) ::before background-image — pass the raw
900 * `url(...)` CSS value through; the parent's
901 * resolveIcon can hand it straight to _makeSvgIcon
902 * regardless of whether it's base64-encoded SVG,
903 * URL-encoded SVG, or a plain http(s) URL. */
904 try {
905 var __wpdBefore = window.getComputedStyle( __wpdImgWrap, '::before' );
906 var __wpdBg = __wpdBefore && __wpdBefore.backgroundImage;
907 if ( __wpdBg && __wpdBg !== 'none' && __wpdBg.indexOf( 'url("")' ) === -1 ) {
908 __wpdHarvest[ __wpdKey ] = __wpdBg;
909 continue;
910 }
911 /* (d) background on the wrap itself */
912 var __wpdWrapBg = window.getComputedStyle( __wpdImgWrap ).backgroundImage;
913 if ( __wpdWrapBg && __wpdWrapBg !== 'none' && __wpdWrapBg.indexOf( 'url("")' ) === -1 ) {
914 __wpdHarvest[ __wpdKey ] = __wpdWrapBg;
915 }
916 } catch ( __wpdE2 ) { /* getComputedStyle may throw on detached nodes */ }
917 }
918
919 var __wpdItems = __DESKTOP_MODE_MENU_PAYLOAD__.dockItems;
920 for ( var __wpdDi = 0; __wpdDi < __wpdItems.length; __wpdDi++ ) {
921 var __wpdItem = __wpdItems[ __wpdDi ];
922 if ( ! __wpdItem || __wpdItem.icon !== 'dashicons-admin-generic' ) { continue; }
923 if ( typeof __wpdItem.url !== 'string' || ! __wpdItem.url ) { continue; }
924 try {
925 var __wpdItemU = new URL( __wpdItem.url, window.location.href );
926 var __wpdItemKey = ( __wpdItemU.pathname.split( '/' ).pop() || '' ) + __wpdItemU.search;
927 if ( __wpdHarvest[ __wpdItemKey ] ) {
928 __wpdItem.icon = __wpdHarvest[ __wpdItemKey ];
929 }
930 } catch ( __wpdE3 ) { /* malformed url — leave icon alone */ }
931 }
932 }
933 }
934 } catch ( __wpdHarvestErr ) {
935 /* Harvest is best-effort; on any failure we still ship the
936 * server-built payload, which is exactly the pre-fix behavior. */
937 }
938 try {
939 if ( __DESKTOP_MODE_MENU_PAYLOAD__ ) {
940 window.parent.postMessage(
941 {
942 type: 'desktop-mode-plugins-changed',
943 payload: __DESKTOP_MODE_MENU_PAYLOAD__
944 },
945 window.location.origin
946 );
947 }
948 } catch ( err ) {
949 /* postMessage throws only on structured-clone failures, which
950 * this static payload won't hit. Swallow defensively so a
951 * wayward extension wrapping window.parent can't break the
952 * rest of the bridge. */
953 }
954
955 /*
956 * Link & form interceptor.
957 *
958 * Every same-origin wp-admin <a> href and <form> action gets the
959 * `desktop_mode_chromeless=1` flag appended so navigation inside the iframe stays
960 * chromeless. Without this, a stray link to /wp-admin/edit.php (see
961 * Gutenberg's fullscreen close button, help-tab links, "Return to
962 * posts" affordances, etc.) re-renders the full classic admin inside
963 * our window.
964 *
965 * Excluded from rewriting:
966 * - modifier clicks (cmd/ctrl/shift/alt) — user wants to open a
967 * new tab/window, respect that
968 * - target="_blank" / target="_top" / target="_parent"
969 * - download attribute
970 * - in-page anchors (#)
971 * - mailto:, tel:, javascript: schemes
972 * - cross-origin URLs
973 * - URLs that already carry desktop_mode_chromeless=
974 */
975 function rewriteAdminUrl( href, base ) {
976 if ( ! href || href.charAt( 0 ) === '#' ) {
977 return null;
978 }
979 if ( /^(mailto:|tel:|javascript:|data:)/i.test( href ) ) {
980 return null;
981 }
982 var url;
983 try {
984 url = new URL( href, base );
985 } catch ( err ) {
986 return null;
987 }
988 if ( url.origin !== window.location.origin ) {
989 return null;
990 }
991 if ( url.pathname.indexOf( '/wp-admin/' ) === -1 ) {
992 return null;
993 }
994 if ( url.searchParams.has( 'desktop_mode_chromeless' ) ) {
995 return null;
996 }
997 url.searchParams.set( 'desktop_mode_chromeless', '1' );
998 return url.toString();
999 }
1000
1001 /*
1002 * Classify a link so we know whether to rewrite it (admin),
1003 * escalate it to the parent shell (external / non-admin), or let
1004 * the browser navigate naturally (mailto, anchor, download, etc.).
1005 *
1006 * 'admin' — same-origin /wp-admin/ URL we rewrite in place.
1007 * 'external' — http(s) URL we want the parent shell to open
1008 * as a sub-tab instead of navigating the iframe
1009 * out of wp-admin. Covers both cross-origin
1010 * links (plugin author sites, external docs) AND
1011 * same-origin non-admin links (the site's own
1012 * front-end pages).
1013 * 'passthrough' — anything else (mailto, tel, javascript, data,
1014 * anchors, unparseable). The browser handles it.
1015 */
1016 function classifyLink( href, base ) {
1017 if ( ! href || href.charAt( 0 ) === '#' ) {
1018 return 'passthrough';
1019 }
1020 if ( /^(mailto:|tel:|javascript:|data:)/i.test( href ) ) {
1021 return 'passthrough';
1022 }
1023 var url;
1024 try {
1025 url = new URL( href, base );
1026 } catch ( err ) {
1027 return 'passthrough';
1028 }
1029 if ( url.protocol !== 'http:' && url.protocol !== 'https:' ) {
1030 return 'passthrough';
1031 }
1032 if (
1033 url.origin === window.location.origin &&
1034 url.pathname.indexOf( '/wp-admin/' ) !== -1
1035 ) {
1036 return 'admin';
1037 }
1038 return 'external';
1039 }
1040
1041 document.addEventListener( 'click', function ( e ) {
1042 if ( e.defaultPrevented ) {
1043 return;
1044 }
1045 if ( e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey ) {
1046 return;
1047 }
1048 var link = e.target && e.target.closest ? e.target.closest( 'a[href]' ) : null;
1049 if ( ! link ) {
1050 return;
1051 }
1052 if ( link.target && link.target !== '' && link.target !== '_self' ) {
1053 return;
1054 }
1055 if ( link.hasAttribute( 'download' ) ) {
1056 return;
1057 }
1058 /*
1059 * WordPress core's wp-admin/js/updates.js owns the click on these
1060 * AJAX-driven plugin/theme management buttons — it binds in bubble
1061 * phase and calls preventDefault to take over with an in-place
1062 * AJAX install / update / delete (with its own progress spinner
1063 * and inline success/failure UX). Our capture-phase handler would
1064 * preempt it: preventDefault here fires BEFORE updates.js's own,
1065 * the AJAX call never starts, and the postMessage below diverts
1066 * the user to the link's no-JS fallback URL (update.php?action=
1067 * install-plugin&...) opened as a freshly spawned desktop window.
1068 * That fallback technically completes the install server-side,
1069 * but it's a long blocking page-load with no in-place feedback —
1070 * which is what users perceive as "Install Now keeps loading and
1071 * opens a new tab". Skip these classes so updates.js's bubble
1072 * handler runs as core intended.
1073 */
1074 if (
1075 link.classList.contains( 'install-now' ) ||
1076 link.classList.contains( 'update-link' ) ||
1077 link.classList.contains( 'update-now' ) ||
1078 link.classList.contains( 'delete-plugin' ) ||
1079 link.classList.contains( 'delete-theme' ) ||
1080 link.classList.contains( 'install-theme' )
1081 ) {
1082 return;
1083 }
1084 var href = link.getAttribute( 'href' );
1085 var kind = classifyLink( href, window.location.href );
1086 if ( kind === 'admin' ) {
1087 var rewritten = rewriteAdminUrl( href, window.location.href );
1088 if ( rewritten ) {
1089 link.setAttribute( 'href', rewritten );
1090 }
1091 /*
1092 * Hand admin-internal navigation to the parent shell.
1093 *
1094 * The parent decides what to do with each click:
1095 *
1096 * - Native-window remap hits (e.g. `edit.php` while the
1097 * user has the native Posts opt-in on) → parent opens
1098 * the native window and closes THIS iframe.
1099 * - Same-page nav (pagination, filtering on the same
1100 * `edit.php?post_type=page` screen, etc.) → parent
1101 * drives the iframe's `location.assign()` so the
1102 * in-place navigation matches the user's intent.
1103 * - Cross-page nav (e.g. clicking "Posts" from inside
1104 * the Pages window) → parent opens a new window for
1105 * the destination and leaves THIS iframe untouched,
1106 * so the user keeps both contexts.
1107 *
1108 * We `preventDefault()` so the iframe never starts a
1109 * navigation the parent might want to suppress; otherwise
1110 * cross-page clicks would trash the source window before
1111 * the parent had a chance to react. Modifier-key clicks
1112 * (cmd/ctrl/shift/alt, middle-click) are already filtered
1113 * upstream so the browser's native "open in new tab" path
1114 * still works.
1115 */
1116 e.preventDefault();
1117 try {
1118 var absolute = new URL( rewritten || href, window.location.href ).toString();
1119 /*
1120 * Ship the link's visible text along with the URL so
1121 * the parent can title a freshly-opened window with
1122 * something the user recognises ("Scheduler") instead
1123 * of the URL slug ("tools-php-page-scheduler") when
1124 * the destination has no dock tile to copy a title
1125 * from. The iframe itself never auto-emits a
1126 * title-change, so without this hint the slug-as-
1127 * title fallback would persist for the lifetime of
1128 * the new window.
1129 */
1130 var adminLabel = ( link.textContent || '' ).trim() ||
1131 link.getAttribute( 'title' ) ||
1132 link.getAttribute( 'aria-label' ) ||
1133 '';
1134 window.parent.postMessage(
1135 {
1136 type: 'desktop-mode-iframe-admin-link',
1137 url: absolute,
1138 label: adminLabel.slice( 0, 80 )
1139 },
1140 window.location.origin
1141 );
1142 } catch ( bridgeErr ) {
1143 /* Same-origin postMessage to the same window can only fail in
1144 * a sandbox we don't support — swallow rather than block the
1145 * click. */
1146 }
1147 return;
1148 }
1149 if ( kind === 'external' ) {
1150 /*
1151 * External navigation inside an admin iframe would leave
1152 * the user stranded in a chrome-free version of whatever
1153 * site the link points at. Escalate to the parent shell
1154 * so it opens the URL as a closeable sub-tab (with a
1155 * detach button) alongside the admin tab — the user
1156 * stays inside the desktop shell.
1157 *
1158 * Resolving the href against the document base gives the
1159 * parent an absolute URL it doesn't have to re-resolve.
1160 */
1161 e.preventDefault();
1162 var absolute;
1163 try {
1164 absolute = new URL( href, window.location.href ).toString();
1165 } catch ( err ) {
1166 return;
1167 }
1168 var label = ( link.textContent || '' ).trim() ||
1169 link.getAttribute( 'title' ) ||
1170 absolute;
1171 window.parent.postMessage(
1172 {
1173 type: 'desktop-mode-external-link',
1174 url: absolute,
1175 label: label.slice( 0, 80 )
1176 },
1177 window.location.origin
1178 );
1179 }
1180 }, true );
1181
1182 document.addEventListener( 'submit', function ( e ) {
1183 var form = e.target;
1184 if ( ! form || form.tagName !== 'FORM' ) {
1185 return;
1186 }
1187 var action = form.getAttribute( 'action' );
1188 var rewritten = rewriteAdminUrl( action || window.location.href, window.location.href );
1189 if ( rewritten ) {
1190 form.setAttribute( 'action', rewritten );
1191 }
1192 }, true );
1193
1194 /*
1195 * Focus-request bridge.
1196 *
1197 * Clicks inside an iframe don't cross the browsing-context
1198 * boundary — the parent shell's pointerdown / focusin listeners
1199 * never see them, so without this hook the only way to focus an
1200 * iframe window would be clicking its title bar chrome. Post a
1201 * `desktop-mode-focus-request` message on every pointerdown; the
1202 * parent Window class treats it as an onFocusRequest. Capture
1203 * phase so the signal fires before any stopPropagation inside
1204 * a page's own handlers.
1205 */
1206 document.addEventListener( 'pointerdown', function () {
1207 try {
1208 window.parent.postMessage(
1209 { type: 'desktop-mode-focus-request' },
1210 window.location.origin
1211 );
1212 } catch ( err ) {
1213 /* cross-origin parent (shouldn't happen for chromeless
1214 * pages, but don't let a throw break the bridge) */
1215 }
1216 }, true );
1217
1218 /*
1219 * OS-file drop forwarder. When the user drags a file from the
1220 * host OS into a chromeless admin iframe, intercept the drop
1221 * before the browser's default "navigate the iframe to the
1222 * file" handler fires, and `postMessage` the raw `File[]` up
1223 * to the parent shell so the OS-file drop manager
1224 * (`src/os-file-drop/manager.ts`) can show the upload dialog.
1225 *
1226 * Same-origin postMessage preserves `File` identity — the
1227 * parent receives real `File` objects, no base64 round-trip.
1228 *
1229 * We only intercept drops whose `DataTransfer.types` includes
1230 * `'Files'`. In-page DnD (Gutenberg block reorders, media
1231 * library drags) carries non-`Files` types and passes through
1232 * untouched.
1233 */
1234 function bridgeHasFiles( ev ) {
1235 var t = ev && ev.dataTransfer && ev.dataTransfer.types;
1236 if ( ! t ) {
1237 return false;
1238 }
1239 if ( typeof t.includes === 'function' ) {
1240 return t.includes( 'Files' );
1241 }
1242 if ( typeof t.contains === 'function' ) {
1243 return t.contains( 'Files' );
1244 }
1245 for ( var i = 0; i < t.length; i++ ) {
1246 if ( t[ i ] === 'Files' ) {
1247 return true;
1248 }
1249 }
1250 return false;
1251 }
1252 /*
1253 * Selectors of in-iframe drop receivers we leave alone —
1254 * Gutenberg's drop zone, the legacy media uploader, any
1255 * element a plugin marks with `data-drop-zone`. The whole
1256 * point: file drops onto Gutenberg blocks keep firing
1257 * Gutenberg's handler; only drops on the empty page
1258 * background escalate to the shell.
1259 */
1260 var bridgeDropPassthroughSelectors = [
1261 '.components-drop-zone',
1262 '[data-drop-zone]',
1263 '.uploader-window',
1264 '.media-frame-content'
1265 ];
1266 function bridgeDropTargetWantsFile( target ) {
1267 if ( ! target || ! target.closest ) {
1268 return false;
1269 }
1270 for ( var s = 0; s < bridgeDropPassthroughSelectors.length; s++ ) {
1271 if ( target.closest( bridgeDropPassthroughSelectors[ s ] ) ) {
1272 return true;
1273 }
1274 }
1275 return false;
1276 }
1277 /*
1278 * Bubble phase (not capture): the inner-most handler — Gutenberg's
1279 * drop zone, the legacy media uploader, or a third-party plugin
1280 * like "Administrador de archivos WP" — runs FIRST and gets the
1281 * chance to call `preventDefault()` to claim the drop. Our
1282 * forwarder then runs LAST at the document level and yields to
1283 * anyone who already took ownership.
1284 *
1285 * Two bail conditions, in order:
1286 * 1. `bridgeDropTargetWantsFile()` — the curated allowlist
1287 * (Gutenberg, wp.media, anything tagged `[data-drop-zone]`).
1288 * Kept as the primary check so the well-known core surfaces
1289 * behave identically to before, even if some edge case skips
1290 * the `preventDefault()` step.
1291 * 2. `ev.defaultPrevented` — the universal HTML5 contract: any
1292 * drop zone willing to receive a file calls `preventDefault()`
1293 * on `dragover` (mandatory per spec) and `drop` (to suppress
1294 * the browser's default navigate-to-file). When that's true,
1295 * some inner handler has taken the drop — yield so plugins
1296 * outside the allowlist (WP File Manager, Yoast, etc.) keep
1297 * their native UX.
1298 */
1299 document.addEventListener( 'dragover', function ( ev ) {
1300 if ( ! bridgeHasFiles( ev ) ) {
1301 return;
1302 }
1303 if ( bridgeDropTargetWantsFile( ev.target ) ) {
1304 return;
1305 }
1306 if ( ev.defaultPrevented ) {
1307 return;
1308 }
1309 ev.preventDefault();
1310 if ( ev.dataTransfer ) {
1311 ev.dataTransfer.dropEffect = 'copy';
1312 }
1313 }, false );
1314 document.addEventListener( 'drop', function ( ev ) {
1315 if ( ! bridgeHasFiles( ev ) ) {
1316 return;
1317 }
1318 if ( bridgeDropTargetWantsFile( ev.target ) ) {
1319 return;
1320 }
1321 if ( ev.defaultPrevented ) {
1322 return;
1323 }
1324 ev.preventDefault();
1325 ev.stopPropagation();
1326 var files = [];
1327 if ( ev.dataTransfer && ev.dataTransfer.files ) {
1328 for ( var i = 0; i < ev.dataTransfer.files.length; i++ ) {
1329 files.push( ev.dataTransfer.files[ i ] );
1330 }
1331 }
1332 if ( files.length === 0 ) {
1333 return;
1334 }
1335 try {
1336 window.parent.postMessage(
1337 {
1338 type: 'desktop-mode-os-file-drop',
1339 files: files,
1340 x: ev.clientX,
1341 y: ev.clientY,
1342 },
1343 window.location.origin
1344 );
1345 } catch ( err ) { /* cross-origin parent; swallow */ }
1346 }, false );
1347
1348 /*
1349 * Cmd+K / Ctrl+K forwarder — single-press, unconditional.
1350 *
1351 * Native keydown events don't cross iframe boundaries. Inside a
1352 * chromeless admin page we want exactly ONE command palette: the
1353 * desktop shell's. WordPress's own `core/commands` palette is
1354 * harvested by `__wpdHarvestCommands` below and re-surfaced in the
1355 * shell palette, so there's no reason to ever let the in-page palette
1356 * take the keystroke.
1357 *
1358 * Capture phase + `stopImmediatePropagation` so we win the race
1359 * against Gutenberg / TinyMCE / plugin handlers bound to the same
1360 * shortcut. Shift/Alt modifiers pass through so user shortcuts using
1361 * those combos keep working.
1362 */
1363 document.addEventListener( 'keydown', function ( e ) {
1364 if ( ! ( e.metaKey || e.ctrlKey ) ) return;
1365 if ( e.key !== 'k' && e.key !== 'K' ) return;
1366 if ( e.shiftKey || e.altKey ) return;
1367
1368 e.preventDefault();
1369 e.stopImmediatePropagation();
1370
1371 try {
1372 window.parent.postMessage(
1373 { type: 'desktop-mode-palette-cycle' },
1374 window.location.origin
1375 );
1376 } catch ( err ) { /* cross-origin parent; swallow */ }
1377 }, true );
1378
1379 /*
1380 * Command harvester — bridges `wp.data.select('core/commands')` to
1381 * the parent shell.
1382 *
1383 * On `desktop-mode-commands-subscribe` from the parent, subscribe to
1384 * the `core/commands` store and post `desktop-mode-commands-list` on
1385 * every change (de-duplicated). On `desktop-mode-commands-invoke`, run
1386 * the original callback inside this iframe — the parent fires this
1387 * when the user selects a proxied command from the shell palette.
1388 *
1389 * Commands are classified by dry-invoking their callback inside a
1390 * `window.location`-intercept sandbox: pure-navigation callbacks
1391 * are flagged `navigate` (with the captured URL) so the parent can
1392 * open a new desktop window instead of navigating this iframe out
1393 * of chromeless mode. Everything else is `action` and proxies back
1394 * into this iframe on user selection.
1395 */
1396 var __wpdCommandsSubscribed = false;
1397 var __wpdCommandsLastPayload = '';
1398 var __wpdCommandsDebounceId = null;
1399 var __wpdCommandsOrigin = window.location.origin;
1400 // Cache per command name so the `window.location`-intercept
1401 // sandbox only runs once per command. Re-classifying on every
1402 // store tick would repeatedly fire side-effectful action
1403 // callbacks (preference toggles, modal opens) — unacceptable.
1404 // Keyed by name; value is the frozen classification minus the
1405 // live `label` / `icon` (which we always re-read in case the
1406 // command updated its own metadata).
1407 var __wpdCommandsKindCache = Object.create( null );
1408
1409 function __wpdRenderIconElement( icon ) {
1410 if ( ! icon ) return '';
1411 if ( typeof icon === 'string' ) return '';
1412 if ( ! window.wp || ! window.wp.element || typeof window.wp.element.renderToString !== 'function' ) {
1413 return '';
1414 }
1415 try {
1416 var rendered = window.wp.element.renderToString( icon );
1417 // `@wordpress/icons` entries render as a complete `<svg>`
1418 // tag. Anything else (wrapped components, empty fragments,
1419 // strings) falls back to dashicons in the palette — we only
1420 // accept markup we can inject straight into the icon slot.
1421 if ( typeof rendered === 'string' && rendered.toLowerCase().indexOf( '<svg' ) === 0 ) {
1422 return rendered;
1423 }
1424 } catch ( _err ) { /* swallow */ }
1425 return '';
1426 }
1427
1428 function __wpdClassifyCommand( cmd ) {
1429 // Defensive defaults — a broken registry should not tank the bridge.
1430 var out = {
1431 name: String( cmd && cmd.name ? cmd.name : '' ),
1432 label: String( cmd && cmd.label ? cmd.label : '' ),
1433 icon: cmd && cmd.icon && typeof cmd.icon === 'string' ? cmd.icon : undefined,
1434 iconSvg: undefined,
1435 context: cmd && cmd.context ? String( cmd.context ) : undefined,
1436 kind: 'action',
1437 url: undefined
1438 };
1439 if ( ! cmd || typeof cmd.callback !== 'function' ) {
1440 return out;
1441 }
1442
1443 // Short-circuit on cached classifications — `renderToString` on
1444 // the React icon is expensive, and the static URL regex scan
1445 // on `callback.toString()` is pure CPU we've already paid once.
1446 var cached = __wpdCommandsKindCache[ out.name ];
1447 if ( cached ) {
1448 out.kind = cached.kind;
1449 out.url = cached.url;
1450 out.iconSvg = cached.iconSvg;
1451 return out;
1452 }
1453
1454 // Render the React icon once per command name — Gutenberg
1455 // commands ship `icon` as a `@wordpress/icons` React element
1456 // the postMessage bridge can't serialize, so we flatten it to
1457 // a static SVG string here.
1458 if ( cmd.icon && typeof cmd.icon !== 'string' ) {
1459 out.iconSvg = __wpdRenderIconElement( cmd.icon );
1460 }
1461
1462 // STATIC classification — read the callback's source text and
1463 // look for a string-literal navigation target. We deliberately
1464 // do NOT execute the callback. An earlier iteration tried a
1465 // dry-run with a `window.location` intercept sandbox, but
1466 // `Location.prototype.href` is non-configurable: the shim
1467 // silently failed, every nav callback actually navigated the
1468 // iframe, the new page re-harvested, and the cascade opened
1469 // windows forever.
1470 //
1471 // Cases caught (WP's @wordpress/core-commands callbacks are
1472 // all of this shape):
1473 // document.location.href = 'url'
1474 // window.location.href = "url"
1475 // location.href = `url`
1476 // location.assign( 'url' )
1477 // location.replace( 'url' )
1478 //
1479 // Computed URLs (template-literal interpolation, addQueryArgs
1480 // calls, variables) fall back to `action` — the user picking
1481 // them will still run the real callback inside the iframe,
1482 // which is the safe default.
1483 var src = '';
1484 try { src = Function.prototype.toString.call( cmd.callback ); } catch ( _err ) { src = ''; }
1485 var navRe = /(?:document\.location\.href|window\.location\.href|location\.href)\s*=\s*['"]([^'"$]+?)['"]/;
1486 var asgRe = /location\.(?:assign|replace)\s*\(\s*['"]([^'"$]+?)['"]\s*\)/;
1487 var mm = src.match( navRe ) || src.match( asgRe );
1488 if ( mm && mm[ 1 ] ) {
1489 try {
1490 out.url = new URL( mm[ 1 ], window.location.href ).toString();
1491 out.kind = 'navigate';
1492 } catch ( _err ) {
1493 out.kind = 'action';
1494 }
1495 }
1496 __wpdCommandsKindCache[ out.name ] = { kind: out.kind, url: out.url, iconSvg: out.iconSvg };
1497 return out;
1498 }
1499
1500 // Harvested commands accumulate here. The React harvester writes
1501 // the full list each render; `__wpdPostCommandsList` reads + posts.
1502 var __wpdLastRawCommands = [];
1503 // Name → live `callback` reference. Loader-returned commands are
1504 // NOT in `wp.data.select('core/commands').getCommands()` — the
1505 // store only exposes statically-registered entries. Without a
1506 // private cache keyed off the React harvester's most recent render,
1507 // invoking a loader command from the parent palette ("Duplicate
1508 // block", "Transform to...", pattern commands) would silently fall
1509 // through to the `getCommands()` lookup and no-op.
1510 var __wpdCommandCallbacks = Object.create( null );
1511
1512 function __wpdFinalizeCommands( raw ) {
1513 var seen = Object.create( null );
1514 var out = [];
1515 var skipped = { missing: 0, disabled: 0, dup: 0 };
1516 for ( var i = 0; i < raw.length; i++ ) {
1517 var cmd = raw[ i ];
1518 if ( ! cmd || ! cmd.name || ! cmd.label ) { skipped.missing++; continue; }
1519 if ( cmd.disabled ) { skipped.disabled++; continue; }
1520 if ( seen[ cmd.name ] ) { skipped.dup++; continue; }
1521 seen[ cmd.name ] = true;
1522 out.push( __wpdClassifyCommand( cmd ) );
1523 }
1524 return out;
1525 }
1526
1527 function __wpdHarvestCommands() {
1528 return __wpdFinalizeCommands( __wpdLastRawCommands );
1529 }
1530
1531 // React-mounted harvester. Block-level / editor-contextual commands
1532 // (tier 3 loaders like `core/block-editor/selected-block-commands`,
1533 // `core/edit-post/pattern-commands`) are React *hooks* — they call
1534 // `useSelect` internally, which only works inside a function-
1535 // component render. So we mount an invisible React tree whose
1536 // children invoke each loader's hook at render time. On every
1537 // re-render (block selection changes, entity edits, welcome guide
1538 // toggled) the effect re-posts the fresh command list to the
1539 // parent. One component per loader keeps the rules-of-hooks
1540 // contract — the hook count inside each `LoaderSlot` is fixed at
1541 // one call (plus the constant `useEffect`), so React's reconciler
1542 // is happy.
1543 var __wpdReactMounted = false;
1544 // Stashed so `__wpdUnsubscribeCommands` can tear the harvester
1545 // down when focus leaves the window — otherwise the component
1546 // keeps re-rendering on every store tick, calling `mergeAndPost`,
1547 // and posting command lists the parent drops on the floor.
1548 var __wpdReactRoot = null;
1549 var __wpdReactHost = null;
1550
1551 function __wpdMountReactHarvester() {
1552 if ( __wpdReactMounted ) return;
1553 if ( ! window.wp || ! window.wp.element || ! window.wp.data ) {
1554 return;
1555 }
1556 var el = window.wp.element;
1557 var createEl = el.createElement;
1558 var useEffect = el.useEffect;
1559 var useRef = el.useRef;
1560 var useMemo = el.useMemo;
1561 var useSelect = ( window.wp.data && window.wp.data.useSelect ) || null;
1562 if ( ! createEl || ! useSelect || ! el.createRoot || ! useRef ) {
1563 return;
1564 }
1565 __wpdReactMounted = true;
1566
1567 // Hidden mount point. Positioned off-screen + `aria-hidden` so
1568 // nothing the harvester renders (it renders null anyway) can
1569 // leak into the accessibility tree or the visible document.
1570 var host = document.createElement( 'div' );
1571 host.setAttribute( 'aria-hidden', 'true' );
1572 host.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden;pointer-events:none;left:-9999px;top:-9999px;';
1573 ( document.body || document.documentElement ).appendChild( host );
1574 __wpdReactHost = host;
1575
1576 // Shared mutable bucket — ref-based aggregation to avoid the
1577 // classic setState-inside-useEffect loop. A `setState` here
1578 // would fire a parent re-render, which would fire the loader
1579 // hook again, which returns a fresh commands array with a new
1580 // reference even when the contents are identical, which would
1581 // re-fire the effect and setState again → Maximum update
1582 // depth exceeded. Refs don't trigger renders, so the loop is
1583 // broken even when hooks churn references.
1584 var resultsBucket = { perLoader: {}, statics: [], loadersList: [] };
1585
1586 function commandsFingerprint( cmds ) {
1587 if ( ! Array.isArray( cmds ) || cmds.length === 0 ) return '';
1588 // Cheap identity — name count is enough to decide whether
1589 // to re-post. Accepts some false negatives (two different
1590 // commands sharing a name) we'll never hit in practice.
1591 var keys = new Array( cmds.length );
1592 for ( var i = 0; i < cmds.length; i++ ) {
1593 var c = cmds[ i ];
1594 keys[ i ] = c && c.name ? c.name : '';
1595 }
1596 return keys.join( '|' );
1597 }
1598
1599 function mergeAndPost() {
1600 var merged = [];
1601 var loadersList = resultsBucket.loadersList;
1602 if ( Array.isArray( loadersList ) ) {
1603 for ( var i = 0; i < loadersList.length; i++ ) {
1604 var bucket = resultsBucket.perLoader[ loadersList[ i ] ];
1605 if ( Array.isArray( bucket ) ) merged = merged.concat( bucket );
1606 }
1607 }
1608 if ( Array.isArray( resultsBucket.statics ) ) {
1609 merged = merged.concat( resultsBucket.statics );
1610 }
1611 // Refresh the callback cache off the SAME snapshot we're
1612 // about to post. Loader-returned commands close over React
1613 // state (selected block, edited entity, etc.) that's only
1614 // valid for this render pass, so rebuilding from scratch
1615 // every merge keeps invoke-from-parent honest instead of
1616 // calling a stale closure.
1617 __wpdCommandCallbacks = Object.create( null );
1618 for ( var j = 0; j < merged.length; j++ ) {
1619 var cc = merged[ j ];
1620 if ( cc && cc.name && typeof cc.callback === 'function' ) {
1621 __wpdCommandCallbacks[ cc.name ] = cc.callback;
1622 }
1623 }
1624 __wpdLastRawCommands = merged;
1625 __wpdSchedulePost();
1626 }
1627
1628 // One slot per loader. Calls the loader's hook at render time;
1629 // an effect keyed on the commands' name-fingerprint writes the
1630 // fresh list into the shared bucket and posts. Ref-based, no
1631 // setState → no re-render cascade.
1632 function LoaderSlot( props ) {
1633 var loader = props.loader;
1634 var result = null;
1635 try {
1636 result = loader.hook( { search: '' } );
1637 } catch ( _err ) {
1638 /* swallow — a buggy loader hook shouldn't take the harvester down */
1639 }
1640 var cmds = ( result && Array.isArray( result.commands ) ) ? result.commands : [];
1641 var key = useMemo( function () { return commandsFingerprint( cmds ); }, [ cmds ] );
1642
1643 useEffect( function () {
1644 resultsBucket.perLoader[ loader.name ] = cmds;
1645 mergeAndPost();
1646 }, [ key ] );
1647
1648 useEffect( function () {
1649 return function () {
1650 delete resultsBucket.perLoader[ loader.name ];
1651 mergeAndPost();
1652 };
1653 }, [] );
1654
1655 return null;
1656 }
1657
1658 function Harvester() {
1659 var loaders = useSelect( function ( s ) {
1660 var ss = s( 'core/commands' );
1661 return ( ss && typeof ss.getCommandLoaders === 'function' )
1662 ? ss.getCommandLoaders( true )
1663 : [];
1664 }, [] );
1665 var staticCmds = useSelect( function ( s ) {
1666 var ss = s( 'core/commands' );
1667 return ( ss && typeof ss.getCommands === 'function' )
1668 ? ss.getCommands( true )
1669 : [];
1670 }, [] );
1671
1672 // Track the loader-name ordering so `mergeAndPost` can emit
1673 // tier-3 in a deterministic order (React reconciliation
1674 // order = registration order = the order the user sees).
1675 var loadersNames = useMemo( function () {
1676 if ( ! Array.isArray( loaders ) ) return [];
1677 return loaders.map( function ( l ) { return l ? l.name : ''; } );
1678 }, [ loaders ] );
1679 var loadersKey = loadersNames.join( '|' );
1680 useEffect( function () {
1681 resultsBucket.loadersList = loadersNames;
1682 mergeAndPost();
1683 }, [ loadersKey ] );
1684
1685 var staticKey = useMemo( function () { return commandsFingerprint( staticCmds ); }, [ staticCmds ] );
1686 useEffect( function () {
1687 resultsBucket.statics = Array.isArray( staticCmds ) ? staticCmds : [];
1688 mergeAndPost();
1689 }, [ staticKey ] );
1690
1691 if ( ! Array.isArray( loaders ) || loaders.length === 0 ) {
1692 return null;
1693 }
1694 var children = [];
1695 for ( var i = 0; i < loaders.length; i++ ) {
1696 var loader = loaders[ i ];
1697 if ( ! loader || typeof loader.hook !== 'function' ) continue;
1698 children.push( createEl( LoaderSlot, {
1699 key: loader.name,
1700 loader: loader
1701 } ) );
1702 }
1703 return createEl( el.Fragment || 'div', null, children );
1704 }
1705
1706 try {
1707 var root = el.createRoot( host );
1708 __wpdReactRoot = root;
1709 root.render( createEl( Harvester ) );
1710 } catch ( err ) {
1711 __wpdReactMounted = false;
1712 __wpdReactRoot = null;
1713 if ( __wpdReactHost && __wpdReactHost.parentNode ) {
1714 __wpdReactHost.parentNode.removeChild( __wpdReactHost );
1715 }
1716 __wpdReactHost = null;
1717 }
1718 }
1719
1720 function __wpdUnmountReactHarvester() {
1721 if ( __wpdReactRoot ) {
1722 try { __wpdReactRoot.unmount(); } catch ( _err ) { /* swallow */ }
1723 }
1724 __wpdReactRoot = null;
1725 if ( __wpdReactHost && __wpdReactHost.parentNode ) {
1726 __wpdReactHost.parentNode.removeChild( __wpdReactHost );
1727 }
1728 __wpdReactHost = null;
1729 __wpdReactMounted = false;
1730 __wpdLastRawCommands = [];
1731 __wpdCommandCallbacks = Object.create( null );
1732 }
1733
1734 function __wpdPostCommandsList() {
1735 var list = __wpdHarvestCommands();
1736 // Cheap de-dupe — the store fires on every unrelated preference
1737 // change too, and shipping an identical payload is pure noise.
1738 // Fingerprint on `name|kind|url` keeps us sensitive to the
1739 // visible surface (name changes, navigate-vs-action flips,
1740 // destination URL changes) while skipping `JSON.stringify` of
1741 // the entire payload — label/icon churn inside a single command
1742 // is rare and re-shipping on it is harmless noise vs. a hot
1743 // path allocation cost.
1744 var key = '';
1745 for ( var k = 0; k < list.length; k++ ) {
1746 var lc = list[ k ];
1747 key += ( lc && lc.name ? lc.name : '' ) + '|'
1748 + ( lc && lc.kind ? lc.kind : '' ) + '|'
1749 + ( lc && lc.url ? lc.url : '' ) + '\n';
1750 }
1751 if ( key === __wpdCommandsLastPayload ) {
1752 return;
1753 }
1754 __wpdCommandsLastPayload = key;
1755 try {
1756 window.parent.postMessage(
1757 { type: 'desktop-mode-commands-list', commands: list },
1758 __wpdCommandsOrigin
1759 );
1760 } catch ( _err ) {
1761 /* cross-origin parent (shouldn't happen for chromeless pages, but
1762 * don't let a throw break the bridge) */
1763 }
1764 }
1765
1766 function __wpdSchedulePost() {
1767 if ( __wpdCommandsDebounceId !== null ) return;
1768 __wpdCommandsDebounceId = window.setTimeout( function () {
1769 __wpdCommandsDebounceId = null;
1770 __wpdPostCommandsList();
1771 }, 60 );
1772 }
1773
1774 function __wpdSubscribeCommands() {
1775 __wpdCommandsSubscribed = true;
1776
1777 // If the React harvester is already running (focus left and
1778 // came back), the bucket still holds the latest merged list.
1779 // Reset the dedupe key so the next post actually ships, then
1780 // schedule it. The harvester itself won't re-fire its effects
1781 // just because the parent re-subscribed — React only reacts to
1782 // store changes, and the store hasn't changed. We have to
1783 // push from here.
1784 if ( __wpdReactMounted ) {
1785 __wpdCommandsLastPayload = '';
1786 __wpdSchedulePost();
1787 return;
1788 }
1789
1790 var attempts = 0;
1791 function tryBind() {
1792 if ( ! __wpdCommandsSubscribed ) return;
1793 if ( ! window.wp || ! window.wp.data || typeof window.wp.data.subscribe !== 'function' ) {
1794 if ( attempts++ < 40 ) {
1795 window.setTimeout( tryBind, 150 );
1796 }
1797 return;
1798 }
1799 // Mount the React harvester — tier 3 loaders are hooks and
1800 // need a legal render context to execute. On every re-render
1801 // the component's effect calls `__wpdSchedulePost` with the
1802 // fresh merged list, so we don't need a separate
1803 // `wp.data.subscribe` callback.
1804 __wpdMountReactHarvester();
1805 }
1806 tryBind();
1807 }
1808
1809 function __wpdUnsubscribeCommands() {
1810 __wpdCommandsSubscribed = false;
1811 __wpdCommandsLastPayload = '';
1812 if ( __wpdCommandsDebounceId !== null ) {
1813 try { window.clearTimeout( __wpdCommandsDebounceId ); } catch ( _err ) { /* swallow */ }
1814 __wpdCommandsDebounceId = null;
1815 }
1816 // Fully tear down the React harvester. Keeping it mounted in
1817 // the background wastes CPU: every store tick re-renders the
1818 // loader hooks, which rebuild the callback cache and post to
1819 // the parent (who drops the message because this window isn't
1820 // the subscribed one). On re-subscribe we remount from scratch.
1821 __wpdUnmountReactHarvester();
1822 }
1823
1824 function __wpdInvokeCommand( name ) {
1825 // Primary lookup — the React harvester's latest snapshot. This
1826 // covers loader-returned commands (Duplicate block, Transform
1827 // to, pattern commands) that never appear in the static
1828 // `getCommands()` list.
1829 var cb = __wpdCommandCallbacks[ name ];
1830 if ( typeof cb === 'function' ) {
1831 try {
1832 cb( { close: function () {} } );
1833 } catch ( _err ) {
1834 /* swallow — a plugin command callback that throws shouldn't break the bridge */
1835 }
1836 return;
1837 }
1838 // Fallback — statically registered commands that never passed
1839 // through the harvester (registered after the last render).
1840 if ( ! window.wp || ! window.wp.data ) {
1841 return;
1842 }
1843 var sel = null;
1844 try { sel = window.wp.data.select( 'core/commands' ); } catch ( _err ) { return; }
1845 if ( ! sel || typeof sel.getCommands !== 'function' ) return;
1846 var raw;
1847 try { raw = sel.getCommands(); } catch ( _err ) { return; }
1848 if ( ! raw ) return;
1849 for ( var i = 0; i < raw.length; i++ ) {
1850 if ( raw[ i ] && raw[ i ].name === name && typeof raw[ i ].callback === 'function' ) {
1851 try {
1852 raw[ i ].callback( { close: function () {} } );
1853 } catch ( _err ) {
1854 /* swallow — see note in primary path above */
1855 }
1856 return;
1857 }
1858 }
1859 }
1860
1861 // Attach the listener BEFORE the bridge-ready ping so a subscribe
1862 // posted synchronously in response is guaranteed to land.
1863 window.addEventListener( 'message', function ( e ) {
1864 if ( e.origin !== __wpdCommandsOrigin ) return;
1865 if ( ! e.data || typeof e.data.type !== 'string' ) return;
1866 if ( e.data.type === 'desktop-mode-commands-subscribe' ) {
1867 __wpdSubscribeCommands();
1868 } else if ( e.data.type === 'desktop-mode-commands-unsubscribe' ) {
1869 __wpdUnsubscribeCommands();
1870 } else if ( e.data.type === 'desktop-mode-commands-invoke' && typeof e.data.name === 'string' ) {
1871 __wpdInvokeCommand( e.data.name );
1872 }
1873 } );
1874
1875 // Handshake: tell the parent we're ready so it can (re)send any
1876 // subscribe that was dispatched before this listener attached.
1877 // Without this ping, a subscribe posted during iframe navigation
1878 // arrives at a context whose message listener isn't installed yet
1879 // and is silently dropped — the symptom is an empty palette even
1880 // though `wp.data.select('core/commands')` is perfectly happy.
1881 try {
1882 window.parent.postMessage(
1883 { type: 'desktop-mode-bridge-ready' },
1884 __wpdCommandsOrigin
1885 );
1886 } catch ( _err ) {
1887 /* parent gone or cross-origin — bridge handshake will retry on next load */
1888 }
1889
1890 /*
1891 * ` / Shift+` forwarder — window switcher.
1892 *
1893 * Bare backtick with no modifier. Must skip when focus is in a
1894 * text-entry element, otherwise typing ` into a block, a text
1895 * field, or TinyMCE would steal the keystroke. Non-text inputs
1896 * (checkbox, button, select) don't accept character input, so
1897 * cycling on those is fine.
1898 *
1899 * Same iframe-crossing rationale as the Cmd+K forwarder above:
1900 * native keydown doesn't reach the parent, so we postMessage.
1901 */
1902 document.addEventListener( 'keydown', function ( e ) {
1903 if ( e.ctrlKey || e.metaKey || e.altKey ) return;
1904 if ( e.code !== 'Backquote' ) return;
1905
1906 // IFRAME case catches Gutenberg: the block canvas is a nested
1907 // iframe, and Gutenberg re-dispatches cloned keydowns up to
1908 // this document for its shortcut system. Without this branch
1909 // typing ` in a block would cycle windows. Any other nested
1910 // iframe owning keyboard handling gets the same treatment.
1911 var el = document.activeElement;
1912 if ( el ) {
1913 var tag = el.tagName;
1914 if ( tag === 'IFRAME' ) return;
1915 if ( tag === 'TEXTAREA' ) return;
1916 if ( tag === 'INPUT' ) {
1917 var type = ( el.type || '' ).toLowerCase();
1918 var textTypes = [
1919 'text', 'search', 'url', 'email', 'password',
1920 'tel', 'number', 'date', 'datetime-local',
1921 'month', 'week', 'time'
1922 ];
1923 if ( textTypes.indexOf( type ) !== -1 ) return;
1924 }
1925 if ( el.isContentEditable ) return;
1926 }
1927
1928 e.preventDefault();
1929 e.stopImmediatePropagation();
1930
1931 try {
1932 window.parent.postMessage(
1933 {
1934 type: 'desktop-mode-window-switch',
1935 direction: e.shiftKey ? 'prev' : 'next'
1936 },
1937 window.location.origin
1938 );
1939 } catch ( err ) { /* cross-origin parent; swallow */ }
1940 }, true );
1941
1942 // Skip if the standalone iframe-bridge bundle already wired
1943 // screen-meta hoisting on this page. Two bridges racing to read
1944 // `aria-expanded` and reflect state would double-fire the
1945 // `desktop-mode-screen-meta-state` message and flicker the
1946 // title-bar buttons.
1947 if ( window.__desktopModeScreenMetaInstalled ) {
1948 return;
1949 }
1950 window.__desktopModeScreenMetaInstalled = true;
1951
1952 var links = document.getElementById( 'screen-meta-links' );
1953 if ( ! links ) {
1954 return;
1955 }
1956 var screenOptionsBtn = document.getElementById( 'show-settings-link' );
1957 var helpBtn = document.getElementById( 'contextual-help-link' );
1958 var panels = [];
1959 if ( screenOptionsBtn ) {
1960 panels.push( 'screen-options' );
1961 }
1962 if ( helpBtn ) {
1963 panels.push( 'help' );
1964 }
1965 if ( panels.length === 0 ) {
1966 return;
1967 }
1968
1969 var origin = window.location.origin;
1970
1971 window.parent.postMessage( {
1972 type: 'desktop-mode-screen-meta',
1973 panels: panels
1974 }, origin );
1975
1976 function getOpenPanel() {
1977 if ( screenOptionsBtn && screenOptionsBtn.getAttribute( 'aria-expanded' ) === 'true' ) {
1978 return 'screen-options';
1979 }
1980 if ( helpBtn && helpBtn.getAttribute( 'aria-expanded' ) === 'true' ) {
1981 return 'help';
1982 }
1983 return null;
1984 }
1985
1986 function reportState() {
1987 window.parent.postMessage( {
1988 type: 'desktop-mode-screen-meta-state',
1989 open: getOpenPanel()
1990 }, origin );
1991 }
1992
1993 reportState();
1994
1995 var observer = new MutationObserver( reportState );
1996 if ( screenOptionsBtn ) {
1997 observer.observe( screenOptionsBtn, { attributes: true, attributeFilter: [ 'aria-expanded' ] } );
1998 }
1999 if ( helpBtn ) {
2000 observer.observe( helpBtn, { attributes: true, attributeFilter: [ 'aria-expanded' ] } );
2001 }
2002
2003 // WP's close() animates and shares #screen-meta between both panels,
2004 // so racing two animated clicks hides the panel that just opened.
2005 // Jump the other panel to its closed end state synchronously instead.
2006 function forceClose( button ) {
2007 if ( ! button || button.getAttribute( 'aria-expanded' ) !== 'true' ) {
2008 return;
2009 }
2010 var panelId = button.getAttribute( 'aria-controls' );
2011 var panel = panelId ? document.getElementById( panelId ) : null;
2012 if ( ! panel ) {
2013 return;
2014 }
2015 if ( window.jQuery ) {
2016 window.jQuery( panel ).stop( true, false );
2017 }
2018 panel.style.display = 'none';
2019 panel.classList.add( 'hidden' );
2020 if ( panel.parentNode instanceof HTMLElement ) {
2021 panel.parentNode.style.display = 'none';
2022 }
2023 button.classList.remove( 'screen-meta-active' );
2024 button.setAttribute( 'aria-expanded', 'false' );
2025 var toggles = document.querySelectorAll( '.screen-meta-toggle' );
2026 for ( var i = 0; i < toggles.length; i++ ) {
2027 toggles[ i ].style.visibility = '';
2028 }
2029 }
2030
2031 /* -----------------------------------------------------------------
2032 * Broadcast receiver — iframe side.
2033 *
2034 * The parent shell publishes broadcasts via
2035 * `wp.desktop.broadcast(topic, payload)` (see `src/broadcast.ts`).
2036 * It posts `{ type: 'desktop-mode-broadcast', topic, payload }` to
2037 * every open iframe. Here we re-dispatch that as a CustomEvent
2038 * on the iframe's own document so admin pages can subscribe with
2039 * plain `document.addEventListener( 'desktop-mode-broadcast', cb )`
2040 * — no extra script handle required.
2041 *
2042 * Iframe-side admin code can also publish UPSTREAM by posting
2043 * the same shape to `window.parent`; the parent's
2044 * `installBroadcastReceiver()` re-broadcasts to every other
2045 * iframe + native window.
2046 * ----------------------------------------------------------------- */
2047 window.addEventListener( 'message', function ( e ) {
2048 if ( e.origin !== origin ) {
2049 return;
2050 }
2051 if ( ! e.data || e.data.type !== 'desktop-mode-broadcast' ) {
2052 return;
2053 }
2054 try {
2055 document.dispatchEvent( new CustomEvent( 'desktop-mode-broadcast', {
2056 detail: { topic: e.data.topic, payload: e.data.payload }
2057 } ) );
2058 } catch ( _err ) { /* old browser without CustomEvent ctor — ignore */ }
2059 } );
2060
2061 /* -----------------------------------------------------------------
2062 * Soft-reload — iframe-side default handler.
2063 *
2064 * When a `desktop-mode.<post_type>.changed` broadcast fires AND the
2065 * current iframe is on a known list page for that post type, we
2066 * fetch the current URL and replace the iframe's `#wpbody-content`
2067 * in place. The user sees the new state of the list — restored
2068 * post appears, deleted media disappears — without the WP loading
2069 * spinner that `location.reload()` would show.
2070 *
2071 * Single-edit pages (`post.php`, `post-new.php`) are deliberately
2072 * NOT in the rule set: replacing their body would destroy any
2073 * unsaved Gutenberg/classic-editor state. Plugins that want
2074 * specific behaviour for those pages can subscribe to the same
2075 * topic on `document` and handle it themselves.
2076 *
2077 * The fetch carries a custom header so a later phase can serve a
2078 * minimal partial response if we want to optimise; for now WP
2079 * returns the full admin page and we just pluck the body.
2080 *
2081 * WP list-table JS uses event delegation on `document`/`body`,
2082 * which survives `replaceWith`. If a specific page breaks after
2083 * a swap (e.g. inline-edit double-binding), that page's plugin
2084 * should listen for `desktop-mode-soft-reloaded` and rebind.
2085 * ----------------------------------------------------------------- */
2086 var DESKTOP_MODE_SOFT_RELOAD_RULES = [
2087 {
2088 topic: 'desktop-mode.post.changed',
2089 match: function () {
2090 if ( ! _desktop_modeEndsWith( location.pathname, '/wp-admin/edit.php' ) ) return false;
2091 var t = new URLSearchParams( location.search ).get( 'post_type' );
2092 return t === null || t === 'post';
2093 }
2094 },
2095 {
2096 topic: 'desktop-mode.page.changed',
2097 match: function () {
2098 if ( ! _desktop_modeEndsWith( location.pathname, '/wp-admin/edit.php' ) ) return false;
2099 return new URLSearchParams( location.search ).get( 'post_type' ) === 'page';
2100 }
2101 },
2102 {
2103 topic: 'desktop-mode.attachment.changed',
2104 match: function () {
2105 return _desktop_modeEndsWith( location.pathname, '/wp-admin/upload.php' );
2106 }
2107 },
2108 {
2109 topic: 'desktop-mode.comment.changed',
2110 match: function () {
2111 return _desktop_modeEndsWith( location.pathname, '/wp-admin/edit-comments.php' );
2112 }
2113 }
2114 ];
2115
2116 function _desktop_modeEndsWith( s, suffix ) { return s.lastIndexOf( suffix ) === s.length - suffix.length; }
2117
2118 var _desktop_modeSoftReloadInFlight = false;
2119 var _desktop_modeSoftReloadQueued = false;
2120
2121 function _desktop_modeSoftReload() {
2122 if ( _desktop_modeSoftReloadInFlight ) {
2123 _desktop_modeSoftReloadQueued = true;
2124 return;
2125 }
2126 _desktop_modeSoftReloadInFlight = true;
2127 fetch( location.href, {
2128 credentials: 'same-origin',
2129 cache: 'no-cache',
2130 headers: { 'X-WP-Desktop-Soft-Reload': '1' }
2131 } ).then( function ( r ) {
2132 if ( ! r.ok ) throw new Error( 'soft-reload fetch failed: ' + r.status );
2133 return r.text();
2134 } ).then( function ( html ) {
2135 var doc = new DOMParser().parseFromString( html, 'text/html' );
2136 var fresh = doc.querySelector( '#wpbody-content' );
2137 var live = document.querySelector( '#wpbody-content' );
2138 if ( ! fresh || ! live ) {
2139 /* Markup we expected isn't there — admin pages we
2140 * don't recognise (or core changes the structure).
2141 * Don't reload; let the iframe stay as it is rather
2142 * than show a spinner the user told us not to. */
2143 return;
2144 }
2145 live.replaceWith( fresh );
2146 try {
2147 document.dispatchEvent( new CustomEvent( 'desktop-mode-soft-reloaded' ) );
2148 } catch ( _err ) {}
2149 /* Some WP scripts re-init on DOMContentLoaded only — let
2150 * pages opt-in to a re-init by listening to the event
2151 * above. We intentionally do NOT re-fire DOMContentLoaded;
2152 * that's almost always wrong (double-init of jQuery/WP). */
2153 } ).catch( function ( err ) {
2154 /* Network error — leave the iframe untouched. The user's
2155 * next manual interaction will refresh state, and the
2156 * next broadcast will retry. */
2157 if ( window.console && window.console.warn ) {
2158 window.console.warn( '[desktop-mode] soft-reload skipped:', err );
2159 }
2160 } ).then( function () {
2161 _desktop_modeSoftReloadInFlight = false;
2162 if ( _desktop_modeSoftReloadQueued ) {
2163 _desktop_modeSoftReloadQueued = false;
2164 _desktop_modeSoftReload();
2165 }
2166 } );
2167 }
2168
2169 document.addEventListener( 'desktop-mode-broadcast', function ( e ) {
2170 var detail = e.detail || {};
2171 var topic = detail.topic;
2172 if ( ! topic ) return;
2173 for ( var i = 0; i < DESKTOP_MODE_SOFT_RELOAD_RULES.length; i++ ) {
2174 var r = DESKTOP_MODE_SOFT_RELOAD_RULES[ i ];
2175 if ( r.topic === topic && r.match() ) {
2176 _desktop_modeSoftReload();
2177 return;
2178 }
2179 }
2180 } );
2181
2182 window.addEventListener( 'message', function( e ) {
2183 if ( e.origin !== origin ) {
2184 return;
2185 }
2186 if ( ! e.data || e.data.type !== 'desktop-mode-toggle-panel' ) {
2187 return;
2188 }
2189 var target = null;
2190 if ( e.data.panel === 'screen-options' && screenOptionsBtn ) {
2191 target = screenOptionsBtn;
2192 } else if ( e.data.panel === 'help' && helpBtn ) {
2193 target = helpBtn;
2194 }
2195 if ( ! target ) {
2196 return;
2197 }
2198 if ( target.getAttribute( 'aria-expanded' ) !== 'true' ) {
2199 var other = target === screenOptionsBtn ? helpBtn : screenOptionsBtn;
2200 forceClose( other );
2201 }
2202 target.click();
2203 } );
2204
2205 /* -----------------------------------------------------------------
2206 * Connection bridge — iframe side.
2207 *
2208 * Plugins call `wp.desktop.iframe.publish(topic, payload)` /
2209 * `subscribe(topic, cb)` / `onConnection(cb)` to talk to a parent-
2210 * side `wp.desktop.connect()` caller. The shell only routes;
2211 * topic semantics are plugin-defined.
2212 *
2213 * Connections are tracked locally so `onConnection` can fire when
2214 * the parent opens a new channel (typical use: start emitting
2215 * heavy events only after at least one consumer subscribed). Each
2216 * connection carries a topic-allowlist negotiated at handshake
2217 * time — wildcard ('*') subscribers see everything.
2218 * ----------------------------------------------------------------- */
2219 var _wpdConnections = {};
2220 var _wpdConnectionListeners = [];
2221 var _wpdSubs = {}; // topic → [cb, ...]
2222 var _wpdChannelSubs = {}; // channel → [cb, ...] (window-channel API)
2223 var _wpdParentOrigin = window.location.origin;
2224
2225 function _wpdEmitToParent( connectionId, topic, payload ) {
2226 try {
2227 window.parent.postMessage( {
2228 type: 'desktop-mode-bridge-publish',
2229 connectionId: connectionId,
2230 topic: topic,
2231 payload: payload
2232 }, _wpdParentOrigin );
2233 } catch ( _err ) { /* parent gone */ }
2234 }
2235
2236 window.addEventListener( 'message', function ( ev ) {
2237 if ( ev.origin !== _wpdParentOrigin ) {
2238 return;
2239 }
2240 var data = ev && ev.data;
2241 if ( ! data || typeof data !== 'object' || typeof data.type !== 'string' ) {
2242 return;
2243 }
2244
2245 if ( data.type === 'desktop-mode-bridge-handshake' && typeof data.connectionId === 'string' ) {
2246 if ( _wpdConnections[ data.connectionId ] ) {
2247 /* Re-handshake on iframe-ready re-arm — no-op besides
2248 * acking again so the parent can resume. */
2249 try {
2250 window.parent.postMessage( {
2251 type: 'desktop-mode-bridge-handshake-ack',
2252 connectionId: data.connectionId
2253 }, _wpdParentOrigin );
2254 } catch ( _err ) { /* swallow */ }
2255 return;
2256 }
2257 var conn = {
2258 id: data.connectionId,
2259 topics: Array.isArray( data.topics ) ? data.topics.slice() : []
2260 };
2261 _wpdConnections[ conn.id ] = conn;
2262 try {
2263 window.parent.postMessage( {
2264 type: 'desktop-mode-bridge-handshake-ack',
2265 connectionId: conn.id
2266 }, _wpdParentOrigin );
2267 } catch ( _err ) { /* swallow */ }
2268 for ( var i = 0; i < _wpdConnectionListeners.length; i++ ) {
2269 try {
2270 _wpdConnectionListeners[ i ]( {
2271 id: conn.id,
2272 topics: conn.topics.slice()
2273 } );
2274 } catch ( _err ) { /* swallow listener */ }
2275 }
2276 return;
2277 }
2278
2279 if ( data.type === 'desktop-mode-bridge-publish' && typeof data.topic === 'string' ) {
2280 var bucket = _wpdSubs[ data.topic ];
2281 if ( bucket ) {
2282 for ( var j = 0; j < bucket.length; j++ ) {
2283 try {
2284 bucket[ j ]( data.payload, { topic: data.topic, connectionId: data.connectionId } );
2285 } catch ( _err ) { /* swallow subscriber */ }
2286 }
2287 }
2288 var wildcard = _wpdSubs[ '*' ];
2289 if ( wildcard ) {
2290 for ( var k = 0; k < wildcard.length; k++ ) {
2291 try {
2292 wildcard[ k ]( data.payload, { topic: data.topic, connectionId: data.connectionId } );
2293 } catch ( _err ) { /* swallow */ }
2294 }
2295 }
2296 return;
2297 }
2298
2299 if ( data.type === 'desktop-mode-bridge-disconnect' && typeof data.connectionId === 'string' ) {
2300 delete _wpdConnections[ data.connectionId ];
2301 return;
2302 }
2303
2304 /* Unified window-channel delivery from the parent. Fires
2305 * every `wp.desktop.on( channel, cb )` subscriber for the
2306 * matching channel — same protocol as
2307 * `assets/js/iframe-bridge.js`. */
2308 if ( data.type === 'desktop-mode-window-send' && typeof data.channel === 'string' && data.channel !== '' ) {
2309 var meta = { channel: data.channel };
2310 var cBucket = _wpdChannelSubs[ data.channel ];
2311 if ( cBucket ) {
2312 var cBucketSnap = cBucket.slice();
2313 for ( var ci = 0; ci < cBucketSnap.length; ci++ ) {
2314 try {
2315 cBucketSnap[ ci ]( data.payload, meta );
2316 } catch ( _err ) { /* swallow */ }
2317 }
2318 }
2319 var cWildcard = _wpdChannelSubs[ '*' ];
2320 if ( cWildcard ) {
2321 var cWildcardSnap = cWildcard.slice();
2322 for ( var cw = 0; cw < cWildcardSnap.length; cw++ ) {
2323 try {
2324 cWildcardSnap[ cw ]( data.payload, meta );
2325 } catch ( _err ) { /* swallow */ }
2326 }
2327 }
2328 return;
2329 }
2330 } );
2331
2332 var iframeApi = {
2333 /**
2334 * Publish a payload under a topic. Sent to every connection
2335 * — typical case is one connection per parent caller, but
2336 * a debug console may have several at once.
2337 */
2338 publish: function ( topic, payload ) {
2339 if ( typeof topic !== 'string' || topic === '' ) {
2340 return;
2341 }
2342 var ids = Object.keys( _wpdConnections );
2343 for ( var i = 0; i < ids.length; i++ ) {
2344 _wpdEmitToParent( ids[ i ], topic, payload );
2345 }
2346 },
2347 /**
2348 * Subscribe to a topic. Returns an unsubscribe function.
2349 * Use `'*'` to receive every published payload (debugging).
2350 */
2351 subscribe: function ( topic, cb ) {
2352 if ( typeof topic !== 'string' || topic === '' || typeof cb !== 'function' ) {
2353 return function () {};
2354 }
2355 var bucket = _wpdSubs[ topic ];
2356 if ( ! bucket ) {
2357 bucket = [];
2358 _wpdSubs[ topic ] = bucket;
2359 }
2360 bucket.push( cb );
2361 return function () {
2362 var i = bucket.indexOf( cb );
2363 if ( i >= 0 ) {
2364 bucket.splice( i, 1 );
2365 }
2366 };
2367 },
2368 /**
2369 * Notified whenever a parent caller opens a connection. Use
2370 * to start emitting heavy publish events only when somebody
2371 * is listening.
2372 */
2373 onConnection: function ( cb ) {
2374 if ( typeof cb !== 'function' ) {
2375 return function () {};
2376 }
2377 _wpdConnectionListeners.push( cb );
2378 /* Replay current connections — late subscribers still
2379 * see who's already there. */
2380 var ids = Object.keys( _wpdConnections );
2381 for ( var i = 0; i < ids.length; i++ ) {
2382 try {
2383 cb( {
2384 id: _wpdConnections[ ids[ i ] ].id,
2385 topics: _wpdConnections[ ids[ i ] ].topics.slice()
2386 } );
2387 } catch ( _err ) { /* swallow */ }
2388 }
2389 return function () {
2390 var i = _wpdConnectionListeners.indexOf( cb );
2391 if ( i >= 0 ) {
2392 _wpdConnectionListeners.splice( i, 1 );
2393 }
2394 };
2395 },
2396 /**
2397 * Iframe-initiated connection request. See
2398 * `assets/js/iframe-bridge.js` — same shape, same protocol.
2399 */
2400 requestConnection: function ( opts ) {
2401 opts = opts || {};
2402 var topics = Array.isArray( opts.topics ) ? opts.topics.slice() : [];
2403 var requestId = 'wpdir-' + Math.random().toString( 36 ).slice( 2, 10 );
2404
2405 return new Promise( function ( resolve, reject ) {
2406 var settled = false;
2407 var timeoutMs = typeof opts.timeoutMs === 'number'
2408 ? opts.timeoutMs
2409 : 5000;
2410
2411 function settle( ok, value ) {
2412 if ( settled ) {
2413 return;
2414 }
2415 settled = true;
2416 window.removeEventListener( 'message', onAck );
2417 clearTimeout( timer );
2418 if ( ok ) {
2419 resolve( value );
2420 } else {
2421 reject( value );
2422 }
2423 }
2424
2425 function onAck( ev ) {
2426 if ( ev.origin !== _wpdParentOrigin ) {
2427 return;
2428 }
2429 var d = ev && ev.data;
2430 if (
2431 ! d ||
2432 typeof d !== 'object' ||
2433 d.type !== 'desktop-mode-bridge-connection-ack' ||
2434 d.requestId !== requestId
2435 ) {
2436 return;
2437 }
2438 if ( d.accepted ) {
2439 var summary = {
2440 id: typeof d.connectionId === 'string' ? d.connectionId : '',
2441 topics: topics.slice()
2442 };
2443 if ( typeof opts.onOpen === 'function' ) {
2444 try { opts.onOpen( summary ); } catch ( _err ) { /* swallow */ }
2445 }
2446 settle( true, summary );
2447 } else {
2448 settle( false, new Error( d.reason || 'rejected' ) );
2449 }
2450 }
2451 window.addEventListener( 'message', onAck );
2452
2453 var timer = setTimeout( function () {
2454 settle( false, new Error( 'timeout' ) );
2455 }, timeoutMs );
2456
2457 try {
2458 window.parent.postMessage( {
2459 type: 'desktop-mode-bridge-connection-request',
2460 requestId: requestId,
2461 topics: topics
2462 }, _wpdParentOrigin );
2463 } catch ( err ) {
2464 settle( false, err );
2465 }
2466 } );
2467 }
2468 };
2469
2470 if ( ! window.wp ) { window.wp = {}; }
2471 if ( ! window.wp.desktop ) { window.wp.desktop = {}; }
2472 window.wp.desktop.iframe = iframeApi;
2473
2474 /* Unified window-channel API. Mirror of the equivalent block
2475 * in `assets/js/iframe-bridge.js` — keep both in sync. The
2476 * parent shell posts `desktop-mode-window-send` on
2477 * `Window.send( channel, payload )`; iframe-side handlers
2478 * register via `wp.desktop.on( channel, cb )`. Sending the
2479 * other way (`wp.desktop.send`) posts up to the parent where
2480 * `Window.on( channel, cb )` subscribers fire. */
2481 if ( typeof window.wp.desktop.send !== 'function' ) {
2482 window.wp.desktop.send = function ( channel, payload ) {
2483 if ( typeof channel !== 'string' || channel === '' ) {
2484 return;
2485 }
2486 try {
2487 window.parent.postMessage( {
2488 type: 'desktop-mode-window-publish',
2489 channel: channel,
2490 payload: payload
2491 }, _wpdParentOrigin );
2492 } catch ( _err ) { /* parent gone */ }
2493 };
2494 }
2495 if ( typeof window.wp.desktop.on !== 'function' ) {
2496 window.wp.desktop.on = function ( channel, cb ) {
2497 if ( typeof channel !== 'string' || channel === '' || typeof cb !== 'function' ) {
2498 return function () {};
2499 }
2500 var bucket = _wpdChannelSubs[ channel ];
2501 if ( ! bucket ) {
2502 bucket = [];
2503 _wpdChannelSubs[ channel ] = bucket;
2504 }
2505 bucket.push( cb );
2506 return function () {
2507 var i = bucket.indexOf( cb );
2508 if ( i >= 0 ) {
2509 bucket.splice( i, 1 );
2510 }
2511 };
2512 };
2513 }
2514
2515 /* -----------------------------------------------------------------
2516 * Stale-nonce recovery after wp-auth-check re-authentication.
2517 *
2518 * When the user's session expires while a chromeless window is
2519 * open, core's `wp-auth-check.js` shows its login iframe inside
2520 * this page. After re-auth the auth cookie is fresh — but every
2521 * per-page nonce cached in JS globals
2522 * (`_wpUpdatesSettings.ajax_nonce`, `commonL10n.nonce`, Gutenberg's
2523 * `wpApiSettings.nonce`, etc.) was minted under the OLD nonce-tick
2524 * and is now rejected by `check_ajax_referer`. WP reports that as
2525 * "Cookie check failed" on the next plugin Install / Activate /
2526 * Update click, which is misleading: the cookie is fine; the
2527 * nonce is stale.
2528 *
2529 * Fix: watch jQuery's `heartbeat-tick`. If we ever see
2530 * `wp-auth-check: false` (the modal trigger) and then later see
2531 * the same field flip back to `true`, the user re-authed
2532 * mid-session and every cached nonce in this iframe is stale —
2533 * reload so they regenerate from the fresh session.
2534 *
2535 * Per-iframe scope is intentional: each chromeless iframe carries
2536 * its own jQuery + heartbeat stack and its own nonce caches.
2537 * Siblings recover on their own next tick. We don't broadcast a
2538 * reload to peers because the parent shell may still be running
2539 * core's confirm() prompts and we don't want to surprise-reload
2540 * windows with unsaved state.
2541 *
2542 * If jQuery never loads on this page (rare — most admin screens
2543 * pull it for heartbeat already), this block is a no-op.
2544 * ----------------------------------------------------------------- */
2545 ( function _wpdInstallAuthCheckRecovery() {
2546 var attached = false;
2547 var sawLoggedOut = false;
2548 function attach() {
2549 if ( attached || ! window.jQuery ) {
2550 return;
2551 }
2552 attached = true;
2553 window.jQuery( document ).on( 'heartbeat-tick.wpdAuthRecover', function ( ev, data ) {
2554 if ( ! data || typeof data !== 'object' || ! ( 'wp-auth-check' in data ) ) {
2555 return;
2556 }
2557 if ( data[ 'wp-auth-check' ] === false ) {
2558 sawLoggedOut = true;
2559 return;
2560 }
2561 if ( sawLoggedOut && data[ 'wp-auth-check' ] === true ) {
2562 sawLoggedOut = false;
2563 // Tell the parent shell BEFORE we reload so it
2564 // doesn't have to wait for its own heartbeat
2565 // tick (up to 60s on an idle shell) to discover
2566 // the cookie is fresh. Parent runs its full
2567 // recovery path on receipt — overlay teardown,
2568 // iframe reload sweep, then a hard reload.
2569 try {
2570 if ( window.parent && window.parent !== window ) {
2571 window.parent.postMessage(
2572 { type: 'desktop-mode-reauth-detected' },
2573 window.location.origin
2574 );
2575 }
2576 } catch ( _err ) { /* parent gone */ }
2577 try { window.location.reload(); } catch ( _err ) { /* swallow */ }
2578 }
2579 } );
2580 }
2581 attach();
2582 if ( document.readyState === 'loading' ) {
2583 document.addEventListener( 'DOMContentLoaded', attach, { once: true } );
2584 }
2585 window.addEventListener( 'load', attach, { once: true } );
2586 } )();
2587
2588 /*
2589 * Bridge-ready signal. Every listener installed by this script
2590 * is now wired; let the parent shell know so it can fire
2591 * `HOOKS.IFRAME_READY` and re-arm any connection handshakes
2592 * (`src/connection/index.ts#onIframeReady`) that arrived before
2593 * we were listening. Without this, every consumer of
2594 * `HOOKS.IFRAME_READY` (devtools replay, connection rearm)
2595 * stays silent for the lifetime of the iframe — documented
2596 * surface that never actually fires.
2597 *
2598 * Posted to the parent's own origin only. Wrapped in try/catch
2599 * because cross-origin parents (top-level admin opened outside
2600 * the shell) would throw on the postMessage and we don't want a
2601 * single failed dispatch to wedge anything else above.
2602 */
2603 try {
2604 if ( window.parent && window.parent !== window ) {
2605 window.parent.postMessage(
2606 { type: 'desktop-mode-ready' },
2607 window.location.origin
2608 );
2609 }
2610 } catch ( _err ) { /* parent gone or cross-origin */ }
2611 } )();
2612 JS;
2613
2614 // Substitute the server-built menu payload into the bridge
2615 // script. `wp_json_encode` guarantees safe JSON output — no need
2616 // for an additional escape pass. When the page isn't on our
2617 // menu-altering allowlist the placeholder resolves to `null` and
2618 // the bridge skips the postMessage.
2619 $js = str_replace( '/*__DESKTOP_MODE_MENU_PAYLOAD__*/', $menu_payload_json, $js );
2620
2621 wp_print_inline_script_tag( $js );
2622 }
2623 add_action( 'admin_footer', 'desktop_mode_chromeless_bridge_script' );
2624