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

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