PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.1
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.1
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / render / chromeless-bridge.php

chromeless-bridge.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.1, at includes/render/chromeless-bridge.php

2,689 lines 96.3 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 * Activity-footprint launcher. A "View activity footprint" row
1060 * action (added to the Users list table by
1061 * `desktop_mode_user_footprint_row_action`) carries the target
1062 * user id in `data-desktop-mode-footprint`. The iframe has no
1063 * shell API of its own, so we escalate the click to the parent
1064 * shell, which opens the My WordPress window on that user's
1065 * footprint. Checked BEFORE classifyLink so the link's real
1066 * href — a graceful profile-edit fallback for no-JS — is never
1067 * followed inside the shell. Modifier-key / middle clicks are
1068 * already filtered above, so cmd/ctrl-click still opens that
1069 * fallback in a new browser tab.
1070 */
1071 var footprintAttr = link.getAttribute( 'data-desktop-mode-footprint' );
1072 if ( footprintAttr ) {
1073 var footprintUid = parseInt( footprintAttr, 10 );
1074 if ( footprintUid > 0 ) {
1075 e.preventDefault();
1076 try {
1077 window.parent.postMessage(
1078 {
1079 type: 'desktop-mode-open-user-footprint',
1080 userId: footprintUid,
1081 userName: link.getAttribute( 'data-desktop-mode-footprint-name' ) || ''
1082 },
1083 window.location.origin
1084 );
1085 } catch ( footprintErr ) {
1086 /* Same-origin postMessage can only fail in a sandbox
1087 * we don't support — swallow rather than block the
1088 * click. */
1089 }
1090 return;
1091 }
1092 }
1093 /*
1094 * WordPress core's wp-admin/js/updates.js owns the click on these
1095 * AJAX-driven plugin/theme management buttons — it binds in bubble
1096 * phase and calls preventDefault to take over with an in-place
1097 * AJAX install / update / delete (with its own progress spinner
1098 * and inline success/failure UX). Our capture-phase handler would
1099 * preempt it: preventDefault here fires BEFORE updates.js's own,
1100 * the AJAX call never starts, and the postMessage below diverts
1101 * the user to the link's no-JS fallback URL (update.php?action=
1102 * install-plugin&...) opened as a freshly spawned desktop window.
1103 * That fallback technically completes the install server-side,
1104 * but it's a long blocking page-load with no in-place feedback —
1105 * which is what users perceive as "Install Now keeps loading and
1106 * opens a new tab". Skip these classes so updates.js's bubble
1107 * handler runs as core intended.
1108 */
1109 if (
1110 link.classList.contains( 'install-now' ) ||
1111 link.classList.contains( 'update-link' ) ||
1112 link.classList.contains( 'update-now' ) ||
1113 link.classList.contains( 'delete-plugin' ) ||
1114 link.classList.contains( 'delete-theme' ) ||
1115 link.classList.contains( 'install-theme' )
1116 ) {
1117 return;
1118 }
1119 var href = link.getAttribute( 'href' );
1120 var kind = classifyLink( href, window.location.href );
1121 if ( kind === 'admin' ) {
1122 var rewritten = rewriteAdminUrl( href, window.location.href );
1123 if ( rewritten ) {
1124 link.setAttribute( 'href', rewritten );
1125 }
1126 /*
1127 * Hand admin-internal navigation to the parent shell.
1128 *
1129 * The parent decides what to do with each click:
1130 *
1131 * - Native-window remap hits (e.g. `edit.php` while the
1132 * user has the native Posts opt-in on) → parent opens
1133 * the native window and closes THIS iframe.
1134 * - Same-page nav (pagination, filtering on the same
1135 * `edit.php?post_type=page` screen, etc.) → parent
1136 * drives the iframe's `location.assign()` so the
1137 * in-place navigation matches the user's intent.
1138 * - Cross-page nav (e.g. clicking "Posts" from inside
1139 * the Pages window) → parent opens a new window for
1140 * the destination and leaves THIS iframe untouched,
1141 * so the user keeps both contexts.
1142 *
1143 * We `preventDefault()` so the iframe never starts a
1144 * navigation the parent might want to suppress; otherwise
1145 * cross-page clicks would trash the source window before
1146 * the parent had a chance to react. Modifier-key clicks
1147 * (cmd/ctrl/shift/alt, middle-click) are already filtered
1148 * upstream so the browser's native "open in new tab" path
1149 * still works.
1150 */
1151 e.preventDefault();
1152 try {
1153 var absolute = new URL( rewritten || href, window.location.href ).toString();
1154 /*
1155 * Ship the link's visible text along with the URL so
1156 * the parent can title a freshly-opened window with
1157 * something the user recognises ("Scheduler") instead
1158 * of the URL slug ("tools-php-page-scheduler") when
1159 * the destination has no dock tile to copy a title
1160 * from. The iframe itself never auto-emits a
1161 * title-change, so without this hint the slug-as-
1162 * title fallback would persist for the lifetime of
1163 * the new window.
1164 */
1165 var adminLabel = ( link.textContent || '' ).trim() ||
1166 link.getAttribute( 'title' ) ||
1167 link.getAttribute( 'aria-label' ) ||
1168 '';
1169 window.parent.postMessage(
1170 {
1171 type: 'desktop-mode-iframe-admin-link',
1172 url: absolute,
1173 label: adminLabel.slice( 0, 80 )
1174 },
1175 window.location.origin
1176 );
1177 } catch ( bridgeErr ) {
1178 /* Same-origin postMessage to the same window can only fail in
1179 * a sandbox we don't support — swallow rather than block the
1180 * click. */
1181 }
1182 return;
1183 }
1184 if ( kind === 'external' ) {
1185 /*
1186 * External navigation inside an admin iframe would leave
1187 * the user stranded in a chrome-free version of whatever
1188 * site the link points at. Escalate to the parent shell
1189 * so it opens the URL as a closeable sub-tab (with a
1190 * detach button) alongside the admin tab — the user
1191 * stays inside the desktop shell.
1192 *
1193 * Resolving the href against the document base gives the
1194 * parent an absolute URL it doesn't have to re-resolve.
1195 */
1196 e.preventDefault();
1197 var absolute;
1198 try {
1199 absolute = new URL( href, window.location.href ).toString();
1200 } catch ( err ) {
1201 return;
1202 }
1203 var label = ( link.textContent || '' ).trim() ||
1204 link.getAttribute( 'title' ) ||
1205 absolute;
1206 window.parent.postMessage(
1207 {
1208 type: 'desktop-mode-external-link',
1209 url: absolute,
1210 label: label.slice( 0, 80 )
1211 },
1212 window.location.origin
1213 );
1214 }
1215 }, true );
1216
1217 document.addEventListener( 'submit', function ( e ) {
1218 var form = e.target;
1219 if ( ! form || form.tagName !== 'FORM' ) {
1220 return;
1221 }
1222 var action = form.getAttribute( 'action' );
1223 var rewritten = rewriteAdminUrl( action || window.location.href, window.location.href );
1224 if ( rewritten ) {
1225 form.setAttribute( 'action', rewritten );
1226 }
1227 }, true );
1228
1229 /*
1230 * Focus-request bridge.
1231 *
1232 * Clicks inside an iframe don't cross the browsing-context
1233 * boundary — the parent shell's pointerdown / focusin listeners
1234 * never see them, so without this hook the only way to focus an
1235 * iframe window would be clicking its title bar chrome. Post a
1236 * `desktop-mode-focus-request` message on every pointerdown; the
1237 * parent Window class treats it as an onFocusRequest. Capture
1238 * phase so the signal fires before any stopPropagation inside
1239 * a page's own handlers.
1240 */
1241 document.addEventListener( 'pointerdown', function () {
1242 try {
1243 window.parent.postMessage(
1244 { type: 'desktop-mode-focus-request' },
1245 window.location.origin
1246 );
1247 } catch ( err ) {
1248 /* cross-origin parent (shouldn't happen for chromeless
1249 * pages, but don't let a throw break the bridge) */
1250 }
1251 }, true );
1252
1253 /*
1254 * OS-file drop forwarder. When the user drags a file from the
1255 * host OS into a chromeless admin iframe, intercept the drop
1256 * before the browser's default "navigate the iframe to the
1257 * file" handler fires, and `postMessage` the raw `File[]` up
1258 * to the parent shell so the OS-file drop manager
1259 * (`src/os-file-drop/manager.ts`) can show the upload dialog.
1260 *
1261 * Same-origin postMessage preserves `File` identity — the
1262 * parent receives real `File` objects, no base64 round-trip.
1263 *
1264 * We only intercept drops whose `DataTransfer.types` includes
1265 * `'Files'`. In-page DnD (Gutenberg block reorders, media
1266 * library drags) carries non-`Files` types and passes through
1267 * untouched.
1268 */
1269 function bridgeHasFiles( ev ) {
1270 var t = ev && ev.dataTransfer && ev.dataTransfer.types;
1271 if ( ! t ) {
1272 return false;
1273 }
1274 if ( typeof t.includes === 'function' ) {
1275 return t.includes( 'Files' );
1276 }
1277 if ( typeof t.contains === 'function' ) {
1278 return t.contains( 'Files' );
1279 }
1280 for ( var i = 0; i < t.length; i++ ) {
1281 if ( t[ i ] === 'Files' ) {
1282 return true;
1283 }
1284 }
1285 return false;
1286 }
1287 /*
1288 * Selectors of in-iframe drop receivers we leave alone —
1289 * Gutenberg's drop zone, the legacy media uploader, any
1290 * element a plugin marks with `data-drop-zone`. The whole
1291 * point: file drops onto Gutenberg blocks keep firing
1292 * Gutenberg's handler; only drops on the empty page
1293 * background escalate to the shell.
1294 */
1295 var bridgeDropPassthroughSelectors = [
1296 '.components-drop-zone',
1297 '[data-drop-zone]',
1298 '.uploader-window',
1299 '.media-frame-content'
1300 ];
1301 function bridgeDropTargetWantsFile( target ) {
1302 if ( ! target || ! target.closest ) {
1303 return false;
1304 }
1305 for ( var s = 0; s < bridgeDropPassthroughSelectors.length; s++ ) {
1306 if ( target.closest( bridgeDropPassthroughSelectors[ s ] ) ) {
1307 return true;
1308 }
1309 }
1310 return false;
1311 }
1312 /*
1313 * Bubble phase (not capture): the inner-most handler — Gutenberg's
1314 * drop zone, the legacy media uploader, or a third-party plugin
1315 * like "Administrador de archivos WP" — runs FIRST and gets the
1316 * chance to call `preventDefault()` to claim the drop. Our
1317 * forwarder then runs LAST at the document level and yields to
1318 * anyone who already took ownership.
1319 *
1320 * Two bail conditions, in order:
1321 * 1. `bridgeDropTargetWantsFile()` — the curated allowlist
1322 * (Gutenberg, wp.media, anything tagged `[data-drop-zone]`).
1323 * Kept as the primary check so the well-known core surfaces
1324 * behave identically to before, even if some edge case skips
1325 * the `preventDefault()` step.
1326 * 2. `ev.defaultPrevented` — the universal HTML5 contract: any
1327 * drop zone willing to receive a file calls `preventDefault()`
1328 * on `dragover` (mandatory per spec) and `drop` (to suppress
1329 * the browser's default navigate-to-file). When that's true,
1330 * some inner handler has taken the drop — yield so plugins
1331 * outside the allowlist (WP File Manager, Yoast, etc.) keep
1332 * their native UX.
1333 */
1334 document.addEventListener( 'dragover', function ( ev ) {
1335 if ( ! bridgeHasFiles( ev ) ) {
1336 return;
1337 }
1338 if ( bridgeDropTargetWantsFile( ev.target ) ) {
1339 return;
1340 }
1341 if ( ev.defaultPrevented ) {
1342 return;
1343 }
1344 ev.preventDefault();
1345 if ( ev.dataTransfer ) {
1346 ev.dataTransfer.dropEffect = 'copy';
1347 }
1348 }, false );
1349 document.addEventListener( 'drop', function ( ev ) {
1350 if ( ! bridgeHasFiles( ev ) ) {
1351 return;
1352 }
1353 if ( bridgeDropTargetWantsFile( ev.target ) ) {
1354 return;
1355 }
1356 if ( ev.defaultPrevented ) {
1357 return;
1358 }
1359 ev.preventDefault();
1360 ev.stopPropagation();
1361 var files = [];
1362 if ( ev.dataTransfer && ev.dataTransfer.files ) {
1363 for ( var i = 0; i < ev.dataTransfer.files.length; i++ ) {
1364 files.push( ev.dataTransfer.files[ i ] );
1365 }
1366 }
1367 if ( files.length === 0 ) {
1368 return;
1369 }
1370 try {
1371 window.parent.postMessage(
1372 {
1373 type: 'desktop-mode-os-file-drop',
1374 files: files,
1375 x: ev.clientX,
1376 y: ev.clientY,
1377 },
1378 window.location.origin
1379 );
1380 } catch ( err ) { /* cross-origin parent; swallow */ }
1381 }, false );
1382
1383 /*
1384 * Cmd+K / Ctrl+K forwarder — single-press, unconditional.
1385 *
1386 * Native keydown events don't cross iframe boundaries. Inside a
1387 * chromeless admin page we want exactly ONE command palette: the
1388 * desktop shell's. WordPress's own `core/commands` palette is
1389 * harvested by `__wpdHarvestCommands` below and re-surfaced in the
1390 * shell palette, so there's no reason to ever let the in-page palette
1391 * take the keystroke.
1392 *
1393 * Capture phase + `stopImmediatePropagation` so we win the race
1394 * against Gutenberg / TinyMCE / plugin handlers bound to the same
1395 * shortcut. Shift/Alt modifiers pass through so user shortcuts using
1396 * those combos keep working.
1397 */
1398 document.addEventListener( 'keydown', function ( e ) {
1399 if ( ! ( e.metaKey || e.ctrlKey ) ) return;
1400 if ( e.key !== 'k' && e.key !== 'K' ) return;
1401 if ( e.shiftKey || e.altKey ) return;
1402
1403 e.preventDefault();
1404 e.stopImmediatePropagation();
1405
1406 try {
1407 window.parent.postMessage(
1408 { type: 'desktop-mode-palette-cycle' },
1409 window.location.origin
1410 );
1411 } catch ( err ) { /* cross-origin parent; swallow */ }
1412 }, true );
1413
1414 /*
1415 * Command harvester — bridges `wp.data.select('core/commands')` to
1416 * the parent shell.
1417 *
1418 * On `desktop-mode-commands-subscribe` from the parent, subscribe to
1419 * the `core/commands` store and post `desktop-mode-commands-list` on
1420 * every change (de-duplicated). On `desktop-mode-commands-invoke`, run
1421 * the original callback inside this iframe — the parent fires this
1422 * when the user selects a proxied command from the shell palette.
1423 *
1424 * Commands are classified by dry-invoking their callback inside a
1425 * `window.location`-intercept sandbox: pure-navigation callbacks
1426 * are flagged `navigate` (with the captured URL) so the parent can
1427 * open a new desktop window instead of navigating this iframe out
1428 * of chromeless mode. Everything else is `action` and proxies back
1429 * into this iframe on user selection.
1430 */
1431 var __wpdCommandsSubscribed = false;
1432 var __wpdCommandsLastPayload = '';
1433 var __wpdCommandsDebounceId = null;
1434 var __wpdCommandsOrigin = window.location.origin;
1435 // Cache per command name so the `window.location`-intercept
1436 // sandbox only runs once per command. Re-classifying on every
1437 // store tick would repeatedly fire side-effectful action
1438 // callbacks (preference toggles, modal opens) — unacceptable.
1439 // Keyed by name; value is the frozen classification minus the
1440 // live `label` / `icon` (which we always re-read in case the
1441 // command updated its own metadata).
1442 var __wpdCommandsKindCache = Object.create( null );
1443
1444 function __wpdRenderIconElement( icon ) {
1445 if ( ! icon ) return '';
1446 if ( typeof icon === 'string' ) return '';
1447 if ( ! window.wp || ! window.wp.element || typeof window.wp.element.renderToString !== 'function' ) {
1448 return '';
1449 }
1450 try {
1451 var rendered = window.wp.element.renderToString( icon );
1452 // `@wordpress/icons` entries render as a complete `<svg>`
1453 // tag. Anything else (wrapped components, empty fragments,
1454 // strings) falls back to dashicons in the palette — we only
1455 // accept markup we can inject straight into the icon slot.
1456 if ( typeof rendered === 'string' && rendered.toLowerCase().indexOf( '<svg' ) === 0 ) {
1457 return rendered;
1458 }
1459 } catch ( _err ) { /* swallow */ }
1460 return '';
1461 }
1462
1463 function __wpdClassifyCommand( cmd ) {
1464 // Defensive defaults — a broken registry should not tank the bridge.
1465 var out = {
1466 name: String( cmd && cmd.name ? cmd.name : '' ),
1467 label: String( cmd && cmd.label ? cmd.label : '' ),
1468 icon: cmd && cmd.icon && typeof cmd.icon === 'string' ? cmd.icon : undefined,
1469 iconSvg: undefined,
1470 context: cmd && cmd.context ? String( cmd.context ) : undefined,
1471 kind: 'action',
1472 url: undefined
1473 };
1474 if ( ! cmd || typeof cmd.callback !== 'function' ) {
1475 return out;
1476 }
1477
1478 // Short-circuit on cached classifications — `renderToString` on
1479 // the React icon is expensive, and the static URL regex scan
1480 // on `callback.toString()` is pure CPU we've already paid once.
1481 var cached = __wpdCommandsKindCache[ out.name ];
1482 if ( cached ) {
1483 out.kind = cached.kind;
1484 out.url = cached.url;
1485 out.iconSvg = cached.iconSvg;
1486 return out;
1487 }
1488
1489 // Render the React icon once per command name — Gutenberg
1490 // commands ship `icon` as a `@wordpress/icons` React element
1491 // the postMessage bridge can't serialize, so we flatten it to
1492 // a static SVG string here.
1493 if ( cmd.icon && typeof cmd.icon !== 'string' ) {
1494 out.iconSvg = __wpdRenderIconElement( cmd.icon );
1495 }
1496
1497 // STATIC classification — read the callback's source text and
1498 // look for a string-literal navigation target. We deliberately
1499 // do NOT execute the callback. An earlier iteration tried a
1500 // dry-run with a `window.location` intercept sandbox, but
1501 // `Location.prototype.href` is non-configurable: the shim
1502 // silently failed, every nav callback actually navigated the
1503 // iframe, the new page re-harvested, and the cascade opened
1504 // windows forever.
1505 //
1506 // Cases caught (WP's @wordpress/core-commands callbacks are
1507 // all of this shape):
1508 // document.location.href = 'url'
1509 // window.location.href = "url"
1510 // location.href = `url`
1511 // location.assign( 'url' )
1512 // location.replace( 'url' )
1513 //
1514 // Computed URLs (template-literal interpolation, addQueryArgs
1515 // calls, variables) fall back to `action` — the user picking
1516 // them will still run the real callback inside the iframe,
1517 // which is the safe default.
1518 var src = '';
1519 try { src = Function.prototype.toString.call( cmd.callback ); } catch ( _err ) { src = ''; }
1520 var navRe = /(?:document\.location\.href|window\.location\.href|location\.href)\s*=\s*['"]([^'"$]+?)['"]/;
1521 var asgRe = /location\.(?:assign|replace)\s*\(\s*['"]([^'"$]+?)['"]\s*\)/;
1522 var mm = src.match( navRe ) || src.match( asgRe );
1523 if ( mm && mm[ 1 ] ) {
1524 try {
1525 out.url = new URL( mm[ 1 ], window.location.href ).toString();
1526 out.kind = 'navigate';
1527 } catch ( _err ) {
1528 out.kind = 'action';
1529 }
1530 }
1531 __wpdCommandsKindCache[ out.name ] = { kind: out.kind, url: out.url, iconSvg: out.iconSvg };
1532 return out;
1533 }
1534
1535 // Harvested commands accumulate here. The React harvester writes
1536 // the full list each render; `__wpdPostCommandsList` reads + posts.
1537 var __wpdLastRawCommands = [];
1538 // Name → live `callback` reference. Loader-returned commands are
1539 // NOT in `wp.data.select('core/commands').getCommands()` — the
1540 // store only exposes statically-registered entries. Without a
1541 // private cache keyed off the React harvester's most recent render,
1542 // invoking a loader command from the parent palette ("Duplicate
1543 // block", "Transform to...", pattern commands) would silently fall
1544 // through to the `getCommands()` lookup and no-op.
1545 var __wpdCommandCallbacks = Object.create( null );
1546
1547 function __wpdFinalizeCommands( raw ) {
1548 var seen = Object.create( null );
1549 var out = [];
1550 var skipped = { missing: 0, disabled: 0, dup: 0 };
1551 for ( var i = 0; i < raw.length; i++ ) {
1552 var cmd = raw[ i ];
1553 if ( ! cmd || ! cmd.name || ! cmd.label ) { skipped.missing++; continue; }
1554 if ( cmd.disabled ) { skipped.disabled++; continue; }
1555 if ( seen[ cmd.name ] ) { skipped.dup++; continue; }
1556 seen[ cmd.name ] = true;
1557 out.push( __wpdClassifyCommand( cmd ) );
1558 }
1559 return out;
1560 }
1561
1562 function __wpdHarvestCommands() {
1563 return __wpdFinalizeCommands( __wpdLastRawCommands );
1564 }
1565
1566 // React-mounted harvester. Block-level / editor-contextual commands
1567 // (tier 3 loaders like `core/block-editor/selected-block-commands`,
1568 // `core/edit-post/pattern-commands`) are React *hooks* — they call
1569 // `useSelect` internally, which only works inside a function-
1570 // component render. So we mount an invisible React tree whose
1571 // children invoke each loader's hook at render time. On every
1572 // re-render (block selection changes, entity edits, welcome guide
1573 // toggled) the effect re-posts the fresh command list to the
1574 // parent. One component per loader keeps the rules-of-hooks
1575 // contract — the hook count inside each `LoaderSlot` is fixed at
1576 // one call (plus the constant `useEffect`), so React's reconciler
1577 // is happy.
1578 var __wpdReactMounted = false;
1579 // Stashed so `__wpdUnsubscribeCommands` can tear the harvester
1580 // down when focus leaves the window — otherwise the component
1581 // keeps re-rendering on every store tick, calling `mergeAndPost`,
1582 // and posting command lists the parent drops on the floor.
1583 var __wpdReactRoot = null;
1584 var __wpdReactHost = null;
1585
1586 function __wpdMountReactHarvester() {
1587 if ( __wpdReactMounted ) return;
1588 if ( ! window.wp || ! window.wp.element || ! window.wp.data ) {
1589 return;
1590 }
1591 var el = window.wp.element;
1592 var createEl = el.createElement;
1593 var useEffect = el.useEffect;
1594 var useRef = el.useRef;
1595 var useMemo = el.useMemo;
1596 var useSelect = ( window.wp.data && window.wp.data.useSelect ) || null;
1597 if ( ! createEl || ! useSelect || ! el.createRoot || ! useRef ) {
1598 return;
1599 }
1600 __wpdReactMounted = true;
1601
1602 // Hidden mount point. Positioned off-screen + `aria-hidden` so
1603 // nothing the harvester renders (it renders null anyway) can
1604 // leak into the accessibility tree or the visible document.
1605 var host = document.createElement( 'div' );
1606 host.setAttribute( 'aria-hidden', 'true' );
1607 host.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden;pointer-events:none;left:-9999px;top:-9999px;';
1608 ( document.body || document.documentElement ).appendChild( host );
1609 __wpdReactHost = host;
1610
1611 // Shared mutable bucket — ref-based aggregation to avoid the
1612 // classic setState-inside-useEffect loop. A `setState` here
1613 // would fire a parent re-render, which would fire the loader
1614 // hook again, which returns a fresh commands array with a new
1615 // reference even when the contents are identical, which would
1616 // re-fire the effect and setState again → Maximum update
1617 // depth exceeded. Refs don't trigger renders, so the loop is
1618 // broken even when hooks churn references.
1619 var resultsBucket = { perLoader: {}, statics: [], loadersList: [] };
1620
1621 function commandsFingerprint( cmds ) {
1622 if ( ! Array.isArray( cmds ) || cmds.length === 0 ) return '';
1623 // Cheap identity — name count is enough to decide whether
1624 // to re-post. Accepts some false negatives (two different
1625 // commands sharing a name) we'll never hit in practice.
1626 var keys = new Array( cmds.length );
1627 for ( var i = 0; i < cmds.length; i++ ) {
1628 var c = cmds[ i ];
1629 keys[ i ] = c && c.name ? c.name : '';
1630 }
1631 return keys.join( '|' );
1632 }
1633
1634 function mergeAndPost() {
1635 var merged = [];
1636 var loadersList = resultsBucket.loadersList;
1637 if ( Array.isArray( loadersList ) ) {
1638 for ( var i = 0; i < loadersList.length; i++ ) {
1639 var bucket = resultsBucket.perLoader[ loadersList[ i ] ];
1640 if ( Array.isArray( bucket ) ) merged = merged.concat( bucket );
1641 }
1642 }
1643 if ( Array.isArray( resultsBucket.statics ) ) {
1644 merged = merged.concat( resultsBucket.statics );
1645 }
1646 // Refresh the callback cache off the SAME snapshot we're
1647 // about to post. Loader-returned commands close over React
1648 // state (selected block, edited entity, etc.) that's only
1649 // valid for this render pass, so rebuilding from scratch
1650 // every merge keeps invoke-from-parent honest instead of
1651 // calling a stale closure.
1652 __wpdCommandCallbacks = Object.create( null );
1653 for ( var j = 0; j < merged.length; j++ ) {
1654 var cc = merged[ j ];
1655 if ( cc && cc.name && typeof cc.callback === 'function' ) {
1656 __wpdCommandCallbacks[ cc.name ] = cc.callback;
1657 }
1658 }
1659 __wpdLastRawCommands = merged;
1660 __wpdSchedulePost();
1661 }
1662
1663 // One slot per loader. Calls the loader's hook at render time;
1664 // an effect keyed on the commands' name-fingerprint writes the
1665 // fresh list into the shared bucket and posts. Ref-based, no
1666 // setState → no re-render cascade.
1667 function LoaderSlot( props ) {
1668 var loader = props.loader;
1669 var result = null;
1670 try {
1671 result = loader.hook( { search: '' } );
1672 } catch ( _err ) {
1673 /* swallow — a buggy loader hook shouldn't take the harvester down */
1674 }
1675 var cmds = ( result && Array.isArray( result.commands ) ) ? result.commands : [];
1676 var key = useMemo( function () { return commandsFingerprint( cmds ); }, [ cmds ] );
1677
1678 useEffect( function () {
1679 resultsBucket.perLoader[ loader.name ] = cmds;
1680 mergeAndPost();
1681 }, [ key ] );
1682
1683 useEffect( function () {
1684 return function () {
1685 delete resultsBucket.perLoader[ loader.name ];
1686 mergeAndPost();
1687 };
1688 }, [] );
1689
1690 return null;
1691 }
1692
1693 function Harvester() {
1694 var loaders = useSelect( function ( s ) {
1695 var ss = s( 'core/commands' );
1696 return ( ss && typeof ss.getCommandLoaders === 'function' )
1697 ? ss.getCommandLoaders( true )
1698 : [];
1699 }, [] );
1700 var staticCmds = useSelect( function ( s ) {
1701 var ss = s( 'core/commands' );
1702 return ( ss && typeof ss.getCommands === 'function' )
1703 ? ss.getCommands( true )
1704 : [];
1705 }, [] );
1706
1707 // Track the loader-name ordering so `mergeAndPost` can emit
1708 // tier-3 in a deterministic order (React reconciliation
1709 // order = registration order = the order the user sees).
1710 var loadersNames = useMemo( function () {
1711 if ( ! Array.isArray( loaders ) ) return [];
1712 return loaders.map( function ( l ) { return l ? l.name : ''; } );
1713 }, [ loaders ] );
1714 var loadersKey = loadersNames.join( '|' );
1715 useEffect( function () {
1716 resultsBucket.loadersList = loadersNames;
1717 mergeAndPost();
1718 }, [ loadersKey ] );
1719
1720 var staticKey = useMemo( function () { return commandsFingerprint( staticCmds ); }, [ staticCmds ] );
1721 useEffect( function () {
1722 resultsBucket.statics = Array.isArray( staticCmds ) ? staticCmds : [];
1723 mergeAndPost();
1724 }, [ staticKey ] );
1725
1726 if ( ! Array.isArray( loaders ) || loaders.length === 0 ) {
1727 return null;
1728 }
1729 var children = [];
1730 for ( var i = 0; i < loaders.length; i++ ) {
1731 var loader = loaders[ i ];
1732 if ( ! loader || typeof loader.hook !== 'function' ) continue;
1733 children.push( createEl( LoaderSlot, {
1734 key: loader.name,
1735 loader: loader
1736 } ) );
1737 }
1738 return createEl( el.Fragment || 'div', null, children );
1739 }
1740
1741 try {
1742 var root = el.createRoot( host );
1743 __wpdReactRoot = root;
1744 root.render( createEl( Harvester ) );
1745 } catch ( err ) {
1746 __wpdReactMounted = false;
1747 __wpdReactRoot = null;
1748 if ( __wpdReactHost && __wpdReactHost.parentNode ) {
1749 __wpdReactHost.parentNode.removeChild( __wpdReactHost );
1750 }
1751 __wpdReactHost = null;
1752 }
1753 }
1754
1755 function __wpdUnmountReactHarvester() {
1756 if ( __wpdReactRoot ) {
1757 try { __wpdReactRoot.unmount(); } catch ( _err ) { /* swallow */ }
1758 }
1759 __wpdReactRoot = null;
1760 if ( __wpdReactHost && __wpdReactHost.parentNode ) {
1761 __wpdReactHost.parentNode.removeChild( __wpdReactHost );
1762 }
1763 __wpdReactHost = null;
1764 __wpdReactMounted = false;
1765 __wpdLastRawCommands = [];
1766 __wpdCommandCallbacks = Object.create( null );
1767 }
1768
1769 function __wpdPostCommandsList() {
1770 var list = __wpdHarvestCommands();
1771 // Cheap de-dupe — the store fires on every unrelated preference
1772 // change too, and shipping an identical payload is pure noise.
1773 // Fingerprint on `name|kind|url` keeps us sensitive to the
1774 // visible surface (name changes, navigate-vs-action flips,
1775 // destination URL changes) while skipping `JSON.stringify` of
1776 // the entire payload — label/icon churn inside a single command
1777 // is rare and re-shipping on it is harmless noise vs. a hot
1778 // path allocation cost.
1779 var key = '';
1780 for ( var k = 0; k < list.length; k++ ) {
1781 var lc = list[ k ];
1782 key += ( lc && lc.name ? lc.name : '' ) + '|'
1783 + ( lc && lc.kind ? lc.kind : '' ) + '|'
1784 + ( lc && lc.url ? lc.url : '' ) + '\n';
1785 }
1786 if ( key === __wpdCommandsLastPayload ) {
1787 return;
1788 }
1789 __wpdCommandsLastPayload = key;
1790 try {
1791 window.parent.postMessage(
1792 { type: 'desktop-mode-commands-list', commands: list },
1793 __wpdCommandsOrigin
1794 );
1795 } catch ( _err ) {
1796 /* cross-origin parent (shouldn't happen for chromeless pages, but
1797 * don't let a throw break the bridge) */
1798 }
1799 }
1800
1801 function __wpdSchedulePost() {
1802 if ( __wpdCommandsDebounceId !== null ) return;
1803 __wpdCommandsDebounceId = window.setTimeout( function () {
1804 __wpdCommandsDebounceId = null;
1805 __wpdPostCommandsList();
1806 }, 60 );
1807 }
1808
1809 function __wpdSubscribeCommands() {
1810 __wpdCommandsSubscribed = true;
1811
1812 // If the React harvester is already running (focus left and
1813 // came back), the bucket still holds the latest merged list.
1814 // Reset the dedupe key so the next post actually ships, then
1815 // schedule it. The harvester itself won't re-fire its effects
1816 // just because the parent re-subscribed — React only reacts to
1817 // store changes, and the store hasn't changed. We have to
1818 // push from here.
1819 if ( __wpdReactMounted ) {
1820 __wpdCommandsLastPayload = '';
1821 __wpdSchedulePost();
1822 return;
1823 }
1824
1825 var attempts = 0;
1826 function tryBind() {
1827 if ( ! __wpdCommandsSubscribed ) return;
1828 if ( ! window.wp || ! window.wp.data || typeof window.wp.data.subscribe !== 'function' ) {
1829 if ( attempts++ < 40 ) {
1830 window.setTimeout( tryBind, 150 );
1831 }
1832 return;
1833 }
1834 // Mount the React harvester — tier 3 loaders are hooks and
1835 // need a legal render context to execute. On every re-render
1836 // the component's effect calls `__wpdSchedulePost` with the
1837 // fresh merged list, so we don't need a separate
1838 // `wp.data.subscribe` callback.
1839 __wpdMountReactHarvester();
1840 }
1841 tryBind();
1842 }
1843
1844 function __wpdUnsubscribeCommands() {
1845 __wpdCommandsSubscribed = false;
1846 __wpdCommandsLastPayload = '';
1847 if ( __wpdCommandsDebounceId !== null ) {
1848 try { window.clearTimeout( __wpdCommandsDebounceId ); } catch ( _err ) { /* swallow */ }
1849 __wpdCommandsDebounceId = null;
1850 }
1851 // Fully tear down the React harvester. Keeping it mounted in
1852 // the background wastes CPU: every store tick re-renders the
1853 // loader hooks, which rebuild the callback cache and post to
1854 // the parent (who drops the message because this window isn't
1855 // the subscribed one). On re-subscribe we remount from scratch.
1856 __wpdUnmountReactHarvester();
1857 }
1858
1859 function __wpdInvokeCommand( name ) {
1860 // Primary lookup — the React harvester's latest snapshot. This
1861 // covers loader-returned commands (Duplicate block, Transform
1862 // to, pattern commands) that never appear in the static
1863 // `getCommands()` list.
1864 var cb = __wpdCommandCallbacks[ name ];
1865 if ( typeof cb === 'function' ) {
1866 try {
1867 cb( { close: function () {} } );
1868 } catch ( _err ) {
1869 /* swallow — a plugin command callback that throws shouldn't break the bridge */
1870 }
1871 return;
1872 }
1873 // Fallback — statically registered commands that never passed
1874 // through the harvester (registered after the last render).
1875 if ( ! window.wp || ! window.wp.data ) {
1876 return;
1877 }
1878 var sel = null;
1879 try { sel = window.wp.data.select( 'core/commands' ); } catch ( _err ) { return; }
1880 if ( ! sel || typeof sel.getCommands !== 'function' ) return;
1881 var raw;
1882 try { raw = sel.getCommands(); } catch ( _err ) { return; }
1883 if ( ! raw ) return;
1884 for ( var i = 0; i < raw.length; i++ ) {
1885 if ( raw[ i ] && raw[ i ].name === name && typeof raw[ i ].callback === 'function' ) {
1886 try {
1887 raw[ i ].callback( { close: function () {} } );
1888 } catch ( _err ) {
1889 /* swallow — see note in primary path above */
1890 }
1891 return;
1892 }
1893 }
1894 }
1895
1896 // Attach the listener BEFORE the bridge-ready ping so a subscribe
1897 // posted synchronously in response is guaranteed to land.
1898 window.addEventListener( 'message', function ( e ) {
1899 if ( e.origin !== __wpdCommandsOrigin ) return;
1900 if ( ! e.data || typeof e.data.type !== 'string' ) return;
1901 if ( e.data.type === 'desktop-mode-commands-subscribe' ) {
1902 __wpdSubscribeCommands();
1903 } else if ( e.data.type === 'desktop-mode-commands-unsubscribe' ) {
1904 __wpdUnsubscribeCommands();
1905 } else if ( e.data.type === 'desktop-mode-commands-invoke' && typeof e.data.name === 'string' ) {
1906 __wpdInvokeCommand( e.data.name );
1907 }
1908 } );
1909
1910 // Handshake: tell the parent we're ready so it can (re)send any
1911 // subscribe that was dispatched before this listener attached.
1912 // Without this ping, a subscribe posted during iframe navigation
1913 // arrives at a context whose message listener isn't installed yet
1914 // and is silently dropped — the symptom is an empty palette even
1915 // though `wp.data.select('core/commands')` is perfectly happy.
1916 try {
1917 window.parent.postMessage(
1918 { type: 'desktop-mode-bridge-ready' },
1919 __wpdCommandsOrigin
1920 );
1921 } catch ( _err ) {
1922 /* parent gone or cross-origin — bridge handshake will retry on next load */
1923 }
1924
1925 /*
1926 * ` / Shift+` forwarder — window switcher.
1927 *
1928 * Bare backtick with no modifier. Must skip when focus is in a
1929 * text-entry element, otherwise typing ` into a block, a text
1930 * field, or TinyMCE would steal the keystroke. Non-text inputs
1931 * (checkbox, button, select) don't accept character input, so
1932 * cycling on those is fine.
1933 *
1934 * Same iframe-crossing rationale as the Cmd+K forwarder above:
1935 * native keydown doesn't reach the parent, so we postMessage.
1936 */
1937 document.addEventListener( 'keydown', function ( e ) {
1938 if ( e.ctrlKey || e.metaKey || e.altKey ) return;
1939 if ( e.code !== 'Backquote' ) return;
1940
1941 // IFRAME case catches Gutenberg: the block canvas is a nested
1942 // iframe, and Gutenberg re-dispatches cloned keydowns up to
1943 // this document for its shortcut system. Without this branch
1944 // typing ` in a block would cycle windows. Any other nested
1945 // iframe owning keyboard handling gets the same treatment.
1946 var el = document.activeElement;
1947 if ( el ) {
1948 var tag = el.tagName;
1949 if ( tag === 'IFRAME' ) return;
1950 if ( tag === 'TEXTAREA' ) return;
1951 if ( tag === 'INPUT' ) {
1952 var type = ( el.type || '' ).toLowerCase();
1953 var textTypes = [
1954 'text', 'search', 'url', 'email', 'password',
1955 'tel', 'number', 'date', 'datetime-local',
1956 'month', 'week', 'time'
1957 ];
1958 if ( textTypes.indexOf( type ) !== -1 ) return;
1959 }
1960 if ( el.isContentEditable ) return;
1961 }
1962
1963 e.preventDefault();
1964 e.stopImmediatePropagation();
1965
1966 try {
1967 window.parent.postMessage(
1968 {
1969 type: 'desktop-mode-window-switch',
1970 direction: e.shiftKey ? 'prev' : 'next'
1971 },
1972 window.location.origin
1973 );
1974 } catch ( err ) { /* cross-origin parent; swallow */ }
1975 }, true );
1976
1977 // Skip if the standalone iframe-bridge bundle already wired
1978 // screen-meta hoisting on this page. Two bridges racing to read
1979 // `aria-expanded` and reflect state would double-fire the
1980 // `desktop-mode-screen-meta-state` message and flicker the
1981 // title-bar buttons.
1982 if ( window.__desktopModeScreenMetaInstalled ) {
1983 return;
1984 }
1985 window.__desktopModeScreenMetaInstalled = true;
1986
1987 // Real screen options render form controls (column toggles, a
1988 // per-page input, custom settings). An empty wrap should not
1989 // surface a dead gear button.
1990 function hasScreenOptionsContent() {
1991 var wrap = document.getElementById( 'screen-options-wrap' );
1992 // WP always renders a nonce hidden input and an "Apply" submit
1993 // inside the wrap, so match only interactive option controls
1994 // (toggles, per-page, radios, selects) — never that always-
1995 // present scaffolding — or an empty panel reads as non-empty.
1996 return !! wrap && !! wrap.querySelector( 'input:not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]), select, textarea' );
1997 }
1998 // A help tab registered with empty content + no callback still
1999 // produces #contextual-help-link but an empty panel. Require some
2000 // non-whitespace tab/sidebar text before announcing the button.
2001 function hasHelpContent() {
2002 var wrap = document.getElementById( 'contextual-help-wrap' );
2003 if ( ! wrap ) {
2004 return false;
2005 }
2006 var panelEls = wrap.querySelectorAll( '.help-tab-content, .contextual-help-sidebar' );
2007 for ( var i = 0; i < panelEls.length; i++ ) {
2008 if ( ( panelEls[ i ].textContent || '' ).trim() !== '' ) {
2009 return true;
2010 }
2011 }
2012 return false;
2013 }
2014
2015 var links = document.getElementById( 'screen-meta-links' );
2016 var screenOptionsBtn = links ? document.getElementById( 'show-settings-link' ) : null;
2017 var helpBtn = links ? document.getElementById( 'contextual-help-link' ) : null;
2018 var panels = [];
2019 if ( screenOptionsBtn && hasScreenOptionsContent() ) {
2020 panels.push( 'screen-options' );
2021 }
2022 if ( helpBtn && hasHelpContent() ) {
2023 panels.push( 'help' );
2024 }
2025
2026 var origin = window.location.origin;
2027
2028 // ALWAYS announce — including an empty array — so the parent removes
2029 // stale gear/Help buttons when this page (e.g. after an in-place
2030 // same-slug navigation) has no screen meta. addScreenMetaButtons()
2031 // clears then repopulates, so an empty array removes everything.
2032 window.parent.postMessage( {
2033 type: 'desktop-mode-screen-meta',
2034 panels: panels
2035 }, origin );
2036
2037 if ( panels.length === 0 ) {
2038 return;
2039 }
2040
2041 function getOpenPanel() {
2042 if ( screenOptionsBtn && screenOptionsBtn.getAttribute( 'aria-expanded' ) === 'true' ) {
2043 return 'screen-options';
2044 }
2045 if ( helpBtn && helpBtn.getAttribute( 'aria-expanded' ) === 'true' ) {
2046 return 'help';
2047 }
2048 return null;
2049 }
2050
2051 function reportState() {
2052 window.parent.postMessage( {
2053 type: 'desktop-mode-screen-meta-state',
2054 open: getOpenPanel()
2055 }, origin );
2056 }
2057
2058 reportState();
2059
2060 var observer = new MutationObserver( reportState );
2061 if ( screenOptionsBtn ) {
2062 observer.observe( screenOptionsBtn, { attributes: true, attributeFilter: [ 'aria-expanded' ] } );
2063 }
2064 if ( helpBtn ) {
2065 observer.observe( helpBtn, { attributes: true, attributeFilter: [ 'aria-expanded' ] } );
2066 }
2067
2068 // WP's close() animates and shares #screen-meta between both panels,
2069 // so racing two animated clicks hides the panel that just opened.
2070 // Jump the other panel to its closed end state synchronously instead.
2071 function forceClose( button ) {
2072 if ( ! button || button.getAttribute( 'aria-expanded' ) !== 'true' ) {
2073 return;
2074 }
2075 var panelId = button.getAttribute( 'aria-controls' );
2076 var panel = panelId ? document.getElementById( panelId ) : null;
2077 if ( ! panel ) {
2078 return;
2079 }
2080 if ( window.jQuery ) {
2081 window.jQuery( panel ).stop( true, false );
2082 }
2083 panel.style.display = 'none';
2084 panel.classList.add( 'hidden' );
2085 if ( panel.parentNode instanceof HTMLElement ) {
2086 panel.parentNode.style.display = 'none';
2087 }
2088 button.classList.remove( 'screen-meta-active' );
2089 button.setAttribute( 'aria-expanded', 'false' );
2090 var toggles = document.querySelectorAll( '.screen-meta-toggle' );
2091 for ( var i = 0; i < toggles.length; i++ ) {
2092 toggles[ i ].style.visibility = '';
2093 }
2094 }
2095
2096 /* -----------------------------------------------------------------
2097 * Broadcast receiver — iframe side.
2098 *
2099 * The parent shell publishes broadcasts via
2100 * `wp.desktop.broadcast(topic, payload)` (see `src/broadcast.ts`).
2101 * It posts `{ type: 'desktop-mode-broadcast', topic, payload }` to
2102 * every open iframe. Here we re-dispatch that as a CustomEvent
2103 * on the iframe's own document so admin pages can subscribe with
2104 * plain `document.addEventListener( 'desktop-mode-broadcast', cb )`
2105 * — no extra script handle required.
2106 *
2107 * Iframe-side admin code can also publish UPSTREAM by posting
2108 * the same shape to `window.parent`; the parent's
2109 * `installBroadcastReceiver()` re-broadcasts to every other
2110 * iframe + native window.
2111 * ----------------------------------------------------------------- */
2112 window.addEventListener( 'message', function ( e ) {
2113 if ( e.origin !== origin ) {
2114 return;
2115 }
2116 if ( ! e.data || e.data.type !== 'desktop-mode-broadcast' ) {
2117 return;
2118 }
2119 try {
2120 document.dispatchEvent( new CustomEvent( 'desktop-mode-broadcast', {
2121 detail: { topic: e.data.topic, payload: e.data.payload }
2122 } ) );
2123 } catch ( _err ) { /* old browser without CustomEvent ctor — ignore */ }
2124 } );
2125
2126 /* -----------------------------------------------------------------
2127 * Soft-reload — iframe-side default handler.
2128 *
2129 * When a `desktop-mode.<post_type>.changed` broadcast fires AND the
2130 * current iframe is on a known list page for that post type, we
2131 * fetch the current URL and replace the iframe's `#wpbody-content`
2132 * in place. The user sees the new state of the list — restored
2133 * post appears, deleted media disappears — without the WP loading
2134 * spinner that `location.reload()` would show.
2135 *
2136 * Single-edit pages (`post.php`, `post-new.php`) are deliberately
2137 * NOT in the rule set: replacing their body would destroy any
2138 * unsaved Gutenberg/classic-editor state. Plugins that want
2139 * specific behaviour for those pages can subscribe to the same
2140 * topic on `document` and handle it themselves.
2141 *
2142 * The fetch carries a custom header so a later phase can serve a
2143 * minimal partial response if we want to optimise; for now WP
2144 * returns the full admin page and we just pluck the body.
2145 *
2146 * WP list-table JS uses event delegation on `document`/`body`,
2147 * which survives `replaceWith`. If a specific page breaks after
2148 * a swap (e.g. inline-edit double-binding), that page's plugin
2149 * should listen for `desktop-mode-soft-reloaded` and rebind.
2150 * ----------------------------------------------------------------- */
2151 var DESKTOP_MODE_SOFT_RELOAD_RULES = [
2152 {
2153 topic: 'desktop-mode.post.changed',
2154 match: function () {
2155 if ( ! _desktop_modeEndsWith( location.pathname, '/wp-admin/edit.php' ) ) return false;
2156 var t = new URLSearchParams( location.search ).get( 'post_type' );
2157 return t === null || t === 'post';
2158 }
2159 },
2160 {
2161 topic: 'desktop-mode.page.changed',
2162 match: function () {
2163 if ( ! _desktop_modeEndsWith( location.pathname, '/wp-admin/edit.php' ) ) return false;
2164 return new URLSearchParams( location.search ).get( 'post_type' ) === 'page';
2165 }
2166 },
2167 {
2168 topic: 'desktop-mode.attachment.changed',
2169 match: function () {
2170 return _desktop_modeEndsWith( location.pathname, '/wp-admin/upload.php' );
2171 }
2172 },
2173 {
2174 topic: 'desktop-mode.comment.changed',
2175 match: function () {
2176 return _desktop_modeEndsWith( location.pathname, '/wp-admin/edit-comments.php' );
2177 }
2178 }
2179 ];
2180
2181 function _desktop_modeEndsWith( s, suffix ) { return s.lastIndexOf( suffix ) === s.length - suffix.length; }
2182
2183 var _desktop_modeSoftReloadInFlight = false;
2184 var _desktop_modeSoftReloadQueued = false;
2185
2186 function _desktop_modeSoftReload() {
2187 if ( _desktop_modeSoftReloadInFlight ) {
2188 _desktop_modeSoftReloadQueued = true;
2189 return;
2190 }
2191 _desktop_modeSoftReloadInFlight = true;
2192 fetch( location.href, {
2193 credentials: 'same-origin',
2194 cache: 'no-cache',
2195 headers: { 'X-WP-Desktop-Soft-Reload': '1' }
2196 } ).then( function ( r ) {
2197 if ( ! r.ok ) throw new Error( 'soft-reload fetch failed: ' + r.status );
2198 return r.text();
2199 } ).then( function ( html ) {
2200 var doc = new DOMParser().parseFromString( html, 'text/html' );
2201 var fresh = doc.querySelector( '#wpbody-content' );
2202 var live = document.querySelector( '#wpbody-content' );
2203 if ( ! fresh || ! live ) {
2204 /* Markup we expected isn't there — admin pages we
2205 * don't recognise (or core changes the structure).
2206 * Don't reload; let the iframe stay as it is rather
2207 * than show a spinner the user told us not to. */
2208 return;
2209 }
2210 live.replaceWith( fresh );
2211 try {
2212 document.dispatchEvent( new CustomEvent( 'desktop-mode-soft-reloaded' ) );
2213 } catch ( _err ) {}
2214 /* Some WP scripts re-init on DOMContentLoaded only — let
2215 * pages opt-in to a re-init by listening to the event
2216 * above. We intentionally do NOT re-fire DOMContentLoaded;
2217 * that's almost always wrong (double-init of jQuery/WP). */
2218 } ).catch( function ( err ) {
2219 /* Network error — leave the iframe untouched. The user's
2220 * next manual interaction will refresh state, and the
2221 * next broadcast will retry. */
2222 if ( window.console && window.console.warn ) {
2223 window.console.warn( '[desktop-mode] soft-reload skipped:', err );
2224 }
2225 } ).then( function () {
2226 _desktop_modeSoftReloadInFlight = false;
2227 if ( _desktop_modeSoftReloadQueued ) {
2228 _desktop_modeSoftReloadQueued = false;
2229 _desktop_modeSoftReload();
2230 }
2231 } );
2232 }
2233
2234 document.addEventListener( 'desktop-mode-broadcast', function ( e ) {
2235 var detail = e.detail || {};
2236 var topic = detail.topic;
2237 if ( ! topic ) return;
2238 for ( var i = 0; i < DESKTOP_MODE_SOFT_RELOAD_RULES.length; i++ ) {
2239 var r = DESKTOP_MODE_SOFT_RELOAD_RULES[ i ];
2240 if ( r.topic === topic && r.match() ) {
2241 _desktop_modeSoftReload();
2242 return;
2243 }
2244 }
2245 } );
2246
2247 window.addEventListener( 'message', function( e ) {
2248 if ( e.origin !== origin ) {
2249 return;
2250 }
2251 if ( ! e.data || e.data.type !== 'desktop-mode-toggle-panel' ) {
2252 return;
2253 }
2254 var target = null;
2255 if ( e.data.panel === 'screen-options' && screenOptionsBtn ) {
2256 target = screenOptionsBtn;
2257 } else if ( e.data.panel === 'help' && helpBtn ) {
2258 target = helpBtn;
2259 }
2260 if ( ! target ) {
2261 return;
2262 }
2263 if ( target.getAttribute( 'aria-expanded' ) !== 'true' ) {
2264 var other = target === screenOptionsBtn ? helpBtn : screenOptionsBtn;
2265 forceClose( other );
2266 }
2267 target.click();
2268 } );
2269
2270 /* -----------------------------------------------------------------
2271 * Connection bridge — iframe side.
2272 *
2273 * Plugins call `wp.desktop.iframe.publish(topic, payload)` /
2274 * `subscribe(topic, cb)` / `onConnection(cb)` to talk to a parent-
2275 * side `wp.desktop.connect()` caller. The shell only routes;
2276 * topic semantics are plugin-defined.
2277 *
2278 * Connections are tracked locally so `onConnection` can fire when
2279 * the parent opens a new channel (typical use: start emitting
2280 * heavy events only after at least one consumer subscribed). Each
2281 * connection carries a topic-allowlist negotiated at handshake
2282 * time — wildcard ('*') subscribers see everything.
2283 * ----------------------------------------------------------------- */
2284 var _wpdConnections = {};
2285 var _wpdConnectionListeners = [];
2286 var _wpdSubs = {}; // topic → [cb, ...]
2287 var _wpdChannelSubs = {}; // channel → [cb, ...] (window-channel API)
2288 var _wpdParentOrigin = window.location.origin;
2289
2290 function _wpdEmitToParent( connectionId, topic, payload ) {
2291 try {
2292 window.parent.postMessage( {
2293 type: 'desktop-mode-bridge-publish',
2294 connectionId: connectionId,
2295 topic: topic,
2296 payload: payload
2297 }, _wpdParentOrigin );
2298 } catch ( _err ) { /* parent gone */ }
2299 }
2300
2301 window.addEventListener( 'message', function ( ev ) {
2302 if ( ev.origin !== _wpdParentOrigin ) {
2303 return;
2304 }
2305 var data = ev && ev.data;
2306 if ( ! data || typeof data !== 'object' || typeof data.type !== 'string' ) {
2307 return;
2308 }
2309
2310 if ( data.type === 'desktop-mode-bridge-handshake' && typeof data.connectionId === 'string' ) {
2311 if ( _wpdConnections[ data.connectionId ] ) {
2312 /* Re-handshake on iframe-ready re-arm — no-op besides
2313 * acking again so the parent can resume. */
2314 try {
2315 window.parent.postMessage( {
2316 type: 'desktop-mode-bridge-handshake-ack',
2317 connectionId: data.connectionId
2318 }, _wpdParentOrigin );
2319 } catch ( _err ) { /* swallow */ }
2320 return;
2321 }
2322 var conn = {
2323 id: data.connectionId,
2324 topics: Array.isArray( data.topics ) ? data.topics.slice() : []
2325 };
2326 _wpdConnections[ conn.id ] = conn;
2327 try {
2328 window.parent.postMessage( {
2329 type: 'desktop-mode-bridge-handshake-ack',
2330 connectionId: conn.id
2331 }, _wpdParentOrigin );
2332 } catch ( _err ) { /* swallow */ }
2333 for ( var i = 0; i < _wpdConnectionListeners.length; i++ ) {
2334 try {
2335 _wpdConnectionListeners[ i ]( {
2336 id: conn.id,
2337 topics: conn.topics.slice()
2338 } );
2339 } catch ( _err ) { /* swallow listener */ }
2340 }
2341 return;
2342 }
2343
2344 if ( data.type === 'desktop-mode-bridge-publish' && typeof data.topic === 'string' ) {
2345 var bucket = _wpdSubs[ data.topic ];
2346 if ( bucket ) {
2347 for ( var j = 0; j < bucket.length; j++ ) {
2348 try {
2349 bucket[ j ]( data.payload, { topic: data.topic, connectionId: data.connectionId } );
2350 } catch ( _err ) { /* swallow subscriber */ }
2351 }
2352 }
2353 var wildcard = _wpdSubs[ '*' ];
2354 if ( wildcard ) {
2355 for ( var k = 0; k < wildcard.length; k++ ) {
2356 try {
2357 wildcard[ k ]( data.payload, { topic: data.topic, connectionId: data.connectionId } );
2358 } catch ( _err ) { /* swallow */ }
2359 }
2360 }
2361 return;
2362 }
2363
2364 if ( data.type === 'desktop-mode-bridge-disconnect' && typeof data.connectionId === 'string' ) {
2365 delete _wpdConnections[ data.connectionId ];
2366 return;
2367 }
2368
2369 /* Unified window-channel delivery from the parent. Fires
2370 * every `wp.desktop.on( channel, cb )` subscriber for the
2371 * matching channel — same protocol as
2372 * `assets/js/iframe-bridge.js`. */
2373 if ( data.type === 'desktop-mode-window-send' && typeof data.channel === 'string' && data.channel !== '' ) {
2374 var meta = { channel: data.channel };
2375 var cBucket = _wpdChannelSubs[ data.channel ];
2376 if ( cBucket ) {
2377 var cBucketSnap = cBucket.slice();
2378 for ( var ci = 0; ci < cBucketSnap.length; ci++ ) {
2379 try {
2380 cBucketSnap[ ci ]( data.payload, meta );
2381 } catch ( _err ) { /* swallow */ }
2382 }
2383 }
2384 var cWildcard = _wpdChannelSubs[ '*' ];
2385 if ( cWildcard ) {
2386 var cWildcardSnap = cWildcard.slice();
2387 for ( var cw = 0; cw < cWildcardSnap.length; cw++ ) {
2388 try {
2389 cWildcardSnap[ cw ]( data.payload, meta );
2390 } catch ( _err ) { /* swallow */ }
2391 }
2392 }
2393 return;
2394 }
2395 } );
2396
2397 var iframeApi = {
2398 /**
2399 * Publish a payload under a topic. Sent to every connection
2400 * — typical case is one connection per parent caller, but
2401 * a debug console may have several at once.
2402 */
2403 publish: function ( topic, payload ) {
2404 if ( typeof topic !== 'string' || topic === '' ) {
2405 return;
2406 }
2407 var ids = Object.keys( _wpdConnections );
2408 for ( var i = 0; i < ids.length; i++ ) {
2409 _wpdEmitToParent( ids[ i ], topic, payload );
2410 }
2411 },
2412 /**
2413 * Subscribe to a topic. Returns an unsubscribe function.
2414 * Use `'*'` to receive every published payload (debugging).
2415 */
2416 subscribe: function ( topic, cb ) {
2417 if ( typeof topic !== 'string' || topic === '' || typeof cb !== 'function' ) {
2418 return function () {};
2419 }
2420 var bucket = _wpdSubs[ topic ];
2421 if ( ! bucket ) {
2422 bucket = [];
2423 _wpdSubs[ topic ] = bucket;
2424 }
2425 bucket.push( cb );
2426 return function () {
2427 var i = bucket.indexOf( cb );
2428 if ( i >= 0 ) {
2429 bucket.splice( i, 1 );
2430 }
2431 };
2432 },
2433 /**
2434 * Notified whenever a parent caller opens a connection. Use
2435 * to start emitting heavy publish events only when somebody
2436 * is listening.
2437 */
2438 onConnection: function ( cb ) {
2439 if ( typeof cb !== 'function' ) {
2440 return function () {};
2441 }
2442 _wpdConnectionListeners.push( cb );
2443 /* Replay current connections — late subscribers still
2444 * see who's already there. */
2445 var ids = Object.keys( _wpdConnections );
2446 for ( var i = 0; i < ids.length; i++ ) {
2447 try {
2448 cb( {
2449 id: _wpdConnections[ ids[ i ] ].id,
2450 topics: _wpdConnections[ ids[ i ] ].topics.slice()
2451 } );
2452 } catch ( _err ) { /* swallow */ }
2453 }
2454 return function () {
2455 var i = _wpdConnectionListeners.indexOf( cb );
2456 if ( i >= 0 ) {
2457 _wpdConnectionListeners.splice( i, 1 );
2458 }
2459 };
2460 },
2461 /**
2462 * Iframe-initiated connection request. See
2463 * `assets/js/iframe-bridge.js` — same shape, same protocol.
2464 */
2465 requestConnection: function ( opts ) {
2466 opts = opts || {};
2467 var topics = Array.isArray( opts.topics ) ? opts.topics.slice() : [];
2468 var requestId = 'wpdir-' + Math.random().toString( 36 ).slice( 2, 10 );
2469
2470 return new Promise( function ( resolve, reject ) {
2471 var settled = false;
2472 var timeoutMs = typeof opts.timeoutMs === 'number'
2473 ? opts.timeoutMs
2474 : 5000;
2475
2476 function settle( ok, value ) {
2477 if ( settled ) {
2478 return;
2479 }
2480 settled = true;
2481 window.removeEventListener( 'message', onAck );
2482 clearTimeout( timer );
2483 if ( ok ) {
2484 resolve( value );
2485 } else {
2486 reject( value );
2487 }
2488 }
2489
2490 function onAck( ev ) {
2491 if ( ev.origin !== _wpdParentOrigin ) {
2492 return;
2493 }
2494 var d = ev && ev.data;
2495 if (
2496 ! d ||
2497 typeof d !== 'object' ||
2498 d.type !== 'desktop-mode-bridge-connection-ack' ||
2499 d.requestId !== requestId
2500 ) {
2501 return;
2502 }
2503 if ( d.accepted ) {
2504 var summary = {
2505 id: typeof d.connectionId === 'string' ? d.connectionId : '',
2506 topics: topics.slice()
2507 };
2508 if ( typeof opts.onOpen === 'function' ) {
2509 try { opts.onOpen( summary ); } catch ( _err ) { /* swallow */ }
2510 }
2511 settle( true, summary );
2512 } else {
2513 settle( false, new Error( d.reason || 'rejected' ) );
2514 }
2515 }
2516 window.addEventListener( 'message', onAck );
2517
2518 var timer = setTimeout( function () {
2519 settle( false, new Error( 'timeout' ) );
2520 }, timeoutMs );
2521
2522 try {
2523 window.parent.postMessage( {
2524 type: 'desktop-mode-bridge-connection-request',
2525 requestId: requestId,
2526 topics: topics
2527 }, _wpdParentOrigin );
2528 } catch ( err ) {
2529 settle( false, err );
2530 }
2531 } );
2532 }
2533 };
2534
2535 if ( ! window.wp ) { window.wp = {}; }
2536 if ( ! window.wp.desktop ) { window.wp.desktop = {}; }
2537 window.wp.desktop.iframe = iframeApi;
2538
2539 /* Unified window-channel API. Mirror of the equivalent block
2540 * in `assets/js/iframe-bridge.js` — keep both in sync. The
2541 * parent shell posts `desktop-mode-window-send` on
2542 * `Window.send( channel, payload )`; iframe-side handlers
2543 * register via `wp.desktop.on( channel, cb )`. Sending the
2544 * other way (`wp.desktop.send`) posts up to the parent where
2545 * `Window.on( channel, cb )` subscribers fire. */
2546 if ( typeof window.wp.desktop.send !== 'function' ) {
2547 window.wp.desktop.send = function ( channel, payload ) {
2548 if ( typeof channel !== 'string' || channel === '' ) {
2549 return;
2550 }
2551 try {
2552 window.parent.postMessage( {
2553 type: 'desktop-mode-window-publish',
2554 channel: channel,
2555 payload: payload
2556 }, _wpdParentOrigin );
2557 } catch ( _err ) { /* parent gone */ }
2558 };
2559 }
2560 if ( typeof window.wp.desktop.on !== 'function' ) {
2561 window.wp.desktop.on = function ( channel, cb ) {
2562 if ( typeof channel !== 'string' || channel === '' || typeof cb !== 'function' ) {
2563 return function () {};
2564 }
2565 var bucket = _wpdChannelSubs[ channel ];
2566 if ( ! bucket ) {
2567 bucket = [];
2568 _wpdChannelSubs[ channel ] = bucket;
2569 }
2570 bucket.push( cb );
2571 return function () {
2572 var i = bucket.indexOf( cb );
2573 if ( i >= 0 ) {
2574 bucket.splice( i, 1 );
2575 }
2576 };
2577 };
2578 }
2579
2580 /* -----------------------------------------------------------------
2581 * Stale-nonce recovery after wp-auth-check re-authentication.
2582 *
2583 * When the user's session expires while a chromeless window is
2584 * open, core's `wp-auth-check.js` shows its login iframe inside
2585 * this page. After re-auth the auth cookie is fresh — but every
2586 * per-page nonce cached in JS globals
2587 * (`_wpUpdatesSettings.ajax_nonce`, `commonL10n.nonce`, Gutenberg's
2588 * `wpApiSettings.nonce`, etc.) was minted under the OLD nonce-tick
2589 * and is now rejected by `check_ajax_referer`. WP reports that as
2590 * "Cookie check failed" on the next plugin Install / Activate /
2591 * Update click, which is misleading: the cookie is fine; the
2592 * nonce is stale.
2593 *
2594 * Fix: watch jQuery's `heartbeat-tick`. If we ever see
2595 * `wp-auth-check: false` (the modal trigger) and then later see
2596 * the same field flip back to `true`, the user re-authed
2597 * mid-session and every cached nonce in this iframe is stale —
2598 * reload so they regenerate from the fresh session.
2599 *
2600 * Per-iframe scope is intentional: each chromeless iframe carries
2601 * its own jQuery + heartbeat stack and its own nonce caches.
2602 * Siblings recover on their own next tick. We don't broadcast a
2603 * reload to peers because the parent shell may still be running
2604 * core's confirm() prompts and we don't want to surprise-reload
2605 * windows with unsaved state.
2606 *
2607 * If jQuery never loads on this page (rare — most admin screens
2608 * pull it for heartbeat already), this block is a no-op.
2609 * ----------------------------------------------------------------- */
2610 ( function _wpdInstallAuthCheckRecovery() {
2611 var attached = false;
2612 var sawLoggedOut = false;
2613 function attach() {
2614 if ( attached || ! window.jQuery ) {
2615 return;
2616 }
2617 attached = true;
2618 window.jQuery( document ).on( 'heartbeat-tick.wpdAuthRecover', function ( ev, data ) {
2619 if ( ! data || typeof data !== 'object' || ! ( 'wp-auth-check' in data ) ) {
2620 return;
2621 }
2622 if ( data[ 'wp-auth-check' ] === false ) {
2623 sawLoggedOut = true;
2624 return;
2625 }
2626 if ( sawLoggedOut && data[ 'wp-auth-check' ] === true ) {
2627 sawLoggedOut = false;
2628 // Tell the parent shell BEFORE we reload so it
2629 // doesn't have to wait for its own heartbeat
2630 // tick (up to 60s on an idle shell) to discover
2631 // the cookie is fresh. Parent runs its full
2632 // recovery path on receipt — overlay teardown,
2633 // iframe reload sweep, then a hard reload.
2634 try {
2635 if ( window.parent && window.parent !== window ) {
2636 window.parent.postMessage(
2637 { type: 'desktop-mode-reauth-detected' },
2638 window.location.origin
2639 );
2640 }
2641 } catch ( _err ) { /* parent gone */ }
2642 try { window.location.reload(); } catch ( _err ) { /* swallow */ }
2643 }
2644 } );
2645 }
2646 attach();
2647 if ( document.readyState === 'loading' ) {
2648 document.addEventListener( 'DOMContentLoaded', attach, { once: true } );
2649 }
2650 window.addEventListener( 'load', attach, { once: true } );
2651 } )();
2652
2653 /*
2654 * Bridge-ready signal. Every listener installed by this script
2655 * is now wired; let the parent shell know so it can fire
2656 * `HOOKS.IFRAME_READY` and re-arm any connection handshakes
2657 * (`src/connection/index.ts#onIframeReady`) that arrived before
2658 * we were listening. Without this, every consumer of
2659 * `HOOKS.IFRAME_READY` (devtools replay, connection rearm)
2660 * stays silent for the lifetime of the iframe — documented
2661 * surface that never actually fires.
2662 *
2663 * Posted to the parent's own origin only. Wrapped in try/catch
2664 * because cross-origin parents (top-level admin opened outside
2665 * the shell) would throw on the postMessage and we don't want a
2666 * single failed dispatch to wedge anything else above.
2667 */
2668 try {
2669 if ( window.parent && window.parent !== window ) {
2670 window.parent.postMessage(
2671 { type: 'desktop-mode-ready' },
2672 window.location.origin
2673 );
2674 }
2675 } catch ( _err ) { /* parent gone or cross-origin */ }
2676 } )();
2677 JS;
2678
2679 // Substitute the server-built menu payload into the bridge
2680 // script. `wp_json_encode` guarantees safe JSON output — no need
2681 // for an additional escape pass. When the page isn't on our
2682 // menu-altering allowlist the placeholder resolves to `null` and
2683 // the bridge skips the postMessage.
2684 $js = str_replace( '/*__DESKTOP_MODE_MENU_PAYLOAD__*/', $menu_payload_json, $js );
2685
2686 wp_print_inline_script_tag( $js );
2687 }
2688 add_action( 'admin_footer', 'desktop_mode_chromeless_bridge_script' );
2689