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

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

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