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

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

3,292 lines 118.1 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 /*
1093 * Menu payload / signature target: the SHELL, i.e. the top window —
1094 * not the immediate parent. For a normal window iframe the two are
1095 * the same frame, but the bulk updater nests: update-core.php (the
1096 * window iframe) hosts a progress iframe of `update.php?action=
1097 * update-selected`, whose `iframe_footer()` fires `admin_footer`
1098 * AFTER the upgrades ran — exactly the fresh payload the shell
1099 * wants. Posting that to `window.parent` hands it to the
1100 * update-core.php page, which has no listener, and the dock badge
1101 * stays stale (GH#296). `window.top` reaches the shell from any
1102 * nesting depth; the targetOrigin pin means a cross-origin top
1103 * (foreign page iframing wp-admin) simply never receives it.
1104 */
1105 try {
1106 var __wpdShell = window.top || window.parent;
1107 if ( __DESKTOP_MODE_MENU_PAYLOAD__ ) {
1108 __wpdShell.postMessage(
1109 {
1110 type: 'desktop-mode-plugins-changed',
1111 payload: __DESKTOP_MODE_MENU_PAYLOAD__
1112 },
1113 window.location.origin
1114 );
1115 } else if ( __DESKTOP_MODE_MENU_SIG__ ) {
1116 /*
1117 * No full payload on this page — but we still ship the cheap
1118 * menu signature so the shell can notice a menu change that
1119 * happened somewhere off the plugins/themes/update path (a
1120 * CPT registered via a settings tool, a plugin that adds a
1121 * menu on save, …) and spend a refresh probe only then.
1122 * GH#325.
1123 */
1124 __wpdShell.postMessage(
1125 {
1126 type: 'desktop-mode-menu-signature',
1127 sig: __DESKTOP_MODE_MENU_SIG__
1128 },
1129 window.location.origin
1130 );
1131 }
1132 } catch ( err ) {
1133 /* postMessage throws only on structured-clone failures, which
1134 * this static payload won't hit. Swallow defensively so a
1135 * wayward extension wrapping window.parent can't break the
1136 * rest of the bridge. */
1137 }
1138
1139 /*
1140 * Link & form interceptor.
1141 *
1142 * Every same-origin wp-admin <a> href and <form> action gets the
1143 * `desktop_mode_chromeless=1` flag appended so navigation inside the iframe stays
1144 * chromeless. Without this, a stray link to /wp-admin/edit.php (see
1145 * Gutenberg's fullscreen close button, help-tab links, "Return to
1146 * posts" affordances, etc.) re-renders the full classic admin inside
1147 * our window.
1148 *
1149 * Excluded from rewriting:
1150 * - modifier clicks (cmd/ctrl/shift/alt) — user wants to open a
1151 * new tab/window, respect that
1152 * - target="_blank" / target="_top" / target="_parent"
1153 * - download attribute
1154 * - in-page anchors (#)
1155 * - mailto:, tel:, javascript: schemes
1156 * - cross-origin URLs
1157 * - URLs that already carry desktop_mode_chromeless=
1158 */
1159 function rewriteAdminUrl( href, base ) {
1160 if ( ! href || href.charAt( 0 ) === '#' ) {
1161 return null;
1162 }
1163 if ( /^(mailto:|tel:|javascript:|data:)/i.test( href ) ) {
1164 return null;
1165 }
1166 var url;
1167 try {
1168 url = new URL( href, base );
1169 } catch ( err ) {
1170 return null;
1171 }
1172 if ( url.origin !== window.location.origin ) {
1173 return null;
1174 }
1175 if ( url.pathname.indexOf( '/wp-admin/' ) === -1 ) {
1176 return null;
1177 }
1178 if ( url.searchParams.has( 'desktop_mode_chromeless' ) ) {
1179 return null;
1180 }
1181 url.searchParams.set( 'desktop_mode_chromeless', '1' );
1182 return url.toString();
1183 }
1184
1185 /*
1186 * Classify a link so we know whether to rewrite it (admin),
1187 * escalate it to the parent shell (external / non-admin), or let
1188 * the browser navigate naturally (mailto, anchor, download, etc.).
1189 *
1190 * 'admin' — same-origin /wp-admin/ URL we rewrite in place.
1191 * 'external' — http(s) URL we want the parent shell to open
1192 * as a sub-tab instead of navigating the iframe
1193 * out of wp-admin. Covers both cross-origin
1194 * links (plugin author sites, external docs) AND
1195 * same-origin non-admin links (the site's own
1196 * front-end pages).
1197 * 'passthrough' — anything else (mailto, tel, javascript, data,
1198 * anchors, unparseable). The browser handles it.
1199 */
1200 function classifyLink( href, base ) {
1201 if ( ! href || href.charAt( 0 ) === '#' ) {
1202 return 'passthrough';
1203 }
1204 if ( /^(mailto:|tel:|javascript:|data:)/i.test( href ) ) {
1205 return 'passthrough';
1206 }
1207 var url;
1208 try {
1209 url = new URL( href, base );
1210 } catch ( err ) {
1211 return 'passthrough';
1212 }
1213 if ( url.protocol !== 'http:' && url.protocol !== 'https:' ) {
1214 return 'passthrough';
1215 }
1216 if (
1217 url.origin === window.location.origin &&
1218 url.pathname.indexOf( '/wp-admin/' ) !== -1
1219 ) {
1220 return 'admin';
1221 }
1222 return 'external';
1223 }
1224
1225 document.addEventListener( 'click', function ( e ) {
1226 if ( e.defaultPrevented ) {
1227 return;
1228 }
1229 if ( e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey ) {
1230 return;
1231 }
1232 var link = e.target && e.target.closest ? e.target.closest( 'a[href]' ) : null;
1233 if ( ! link ) {
1234 return;
1235 }
1236 if ( link.target && link.target !== '' && link.target !== '_self' ) {
1237 return;
1238 }
1239 if ( link.hasAttribute( 'download' ) ) {
1240 return;
1241 }
1242 /*
1243 * Activity-footprint launcher. A "View activity footprint" row
1244 * action (added to the Users list table by
1245 * `desktop_mode_user_footprint_row_action`) carries the target
1246 * user id in `data-desktop-mode-footprint`. The iframe has no
1247 * shell API of its own, so we escalate the click to the parent
1248 * shell, which opens the My WordPress window on that user's
1249 * footprint. Checked BEFORE classifyLink so the link's real
1250 * href — a graceful profile-edit fallback for no-JS — is never
1251 * followed inside the shell. Modifier-key / middle clicks are
1252 * already filtered above, so cmd/ctrl-click still opens that
1253 * fallback in a new browser tab.
1254 */
1255 var footprintAttr = link.getAttribute( 'data-desktop-mode-footprint' );
1256 if ( footprintAttr ) {
1257 var footprintUid = parseInt( footprintAttr, 10 );
1258 if ( footprintUid > 0 ) {
1259 e.preventDefault();
1260 try {
1261 window.parent.postMessage(
1262 {
1263 type: 'desktop-mode-open-user-footprint',
1264 userId: footprintUid,
1265 userName: link.getAttribute( 'data-desktop-mode-footprint-name' ) || ''
1266 },
1267 window.location.origin
1268 );
1269 } catch ( footprintErr ) {
1270 /* Same-origin postMessage can only fail in a sandbox
1271 * we don't support — swallow rather than block the
1272 * click. */
1273 }
1274 return;
1275 }
1276 }
1277 /*
1278 * WordPress core's wp-admin/js/updates.js owns the click on these
1279 * AJAX-driven plugin/theme management buttons — it binds in bubble
1280 * phase and calls preventDefault to take over with an in-place
1281 * AJAX install / update / delete (with its own progress spinner
1282 * and inline success/failure UX). Our capture-phase handler would
1283 * preempt it: preventDefault here fires BEFORE updates.js's own,
1284 * the AJAX call never starts, and the postMessage below diverts
1285 * the user to the link's no-JS fallback URL (update.php?action=
1286 * install-plugin&...) opened as a freshly spawned desktop window.
1287 * That fallback technically completes the install server-side,
1288 * but it's a long blocking page-load with no in-place feedback —
1289 * which is what users perceive as "Install Now keeps loading and
1290 * opens a new tab". Skip these classes so updates.js's bubble
1291 * handler runs as core intended.
1292 *
1293 * The plugins-list-table row action "Delete" is the same story
1294 * with a different marker: a bare `a.delete` inside a
1295 * `tr[data-plugin]` (updates.js binds `[data-plugin] a.delete`;
1296 * the network themes list is `.themes-php.network-admin
1297 * a.delete`) — it never carries the `delete-plugin` /
1298 * `delete-theme` classes of the card-style buttons above.
1299 * Hijacking it navigated the iframe to the link's no-JS
1300 * bulk-delete fallback WHILE updates.js's AJAX delete was
1301 * already running: `wp.updates.beforeunload` raised a native
1302 * "Leave site?" prompt, and leaving landed on a delete
1303 * confirmation screen for a plugin whose files the AJAX call
1304 * had just removed — an empty "You are about to remove:" list.
1305 */
1306 if (
1307 link.classList.contains( 'install-now' ) ||
1308 link.classList.contains( 'update-link' ) ||
1309 link.classList.contains( 'update-now' ) ||
1310 link.classList.contains( 'delete-plugin' ) ||
1311 link.classList.contains( 'delete-theme' ) ||
1312 link.classList.contains( 'install-theme' ) ||
1313 ( link.classList.contains( 'delete' ) &&
1314 ( link.closest( '[data-plugin]' ) ||
1315 ( document.body.classList.contains( 'themes-php' ) &&
1316 document.body.classList.contains( 'network-admin' ) ) ) )
1317 ) {
1318 return;
1319 }
1320 var href = link.getAttribute( 'href' );
1321 var kind = classifyLink( href, window.location.href );
1322 if ( kind === 'admin' ) {
1323 var rewritten = rewriteAdminUrl( href, window.location.href );
1324 if ( rewritten ) {
1325 link.setAttribute( 'href', rewritten );
1326 }
1327 /*
1328 * Hand admin-internal navigation to the parent shell.
1329 *
1330 * The parent decides what to do with each click:
1331 *
1332 * - Native-window remap hits (e.g. `edit.php` while the
1333 * user has the native Posts opt-in on) → parent opens
1334 * the native window and closes THIS iframe.
1335 * - Same-page nav (pagination, filtering on the same
1336 * `edit.php?post_type=page` screen, etc.) → parent
1337 * drives the iframe's `location.assign()` so the
1338 * in-place navigation matches the user's intent.
1339 * - Cross-page nav (e.g. clicking "Posts" from inside
1340 * the Pages window) → parent opens a new window for
1341 * the destination and leaves THIS iframe untouched,
1342 * so the user keeps both contexts.
1343 *
1344 * We `preventDefault()` so the iframe never starts a
1345 * navigation the parent might want to suppress; otherwise
1346 * cross-page clicks would trash the source window before
1347 * the parent had a chance to react. Modifier-key clicks
1348 * (cmd/ctrl/shift/alt, middle-click) are already filtered
1349 * upstream so the browser's native "open in new tab" path
1350 * still works.
1351 */
1352 e.preventDefault();
1353 try {
1354 var absolute = new URL( rewritten || href, window.location.href ).toString();
1355 /*
1356 * Ship the link's visible text along with the URL so
1357 * the parent can title a freshly-opened window with
1358 * something the user recognises ("Scheduler") instead
1359 * of the URL slug ("tools-php-page-scheduler") when
1360 * the destination has no dock tile to copy a title
1361 * from. The iframe itself never auto-emits a
1362 * title-change, so without this hint the slug-as-
1363 * title fallback would persist for the lifetime of
1364 * the new window.
1365 */
1366 var adminLabel = ( link.textContent || '' ).trim() ||
1367 link.getAttribute( 'title' ) ||
1368 link.getAttribute( 'aria-label' ) ||
1369 '';
1370 window.parent.postMessage(
1371 {
1372 type: 'desktop-mode-iframe-admin-link',
1373 url: absolute,
1374 label: adminLabel.slice( 0, 80 )
1375 },
1376 window.location.origin
1377 );
1378 } catch ( bridgeErr ) {
1379 /* Same-origin postMessage to the same window can only fail in
1380 * a sandbox we don't support — swallow rather than block the
1381 * click. */
1382 }
1383 return;
1384 }
1385 if ( kind === 'external' ) {
1386 /*
1387 * External navigation inside an admin iframe would leave
1388 * the user stranded in a chrome-free version of whatever
1389 * site the link points at. Escalate to the parent shell
1390 * so it opens the URL as a closeable sub-tab (with a
1391 * detach button) alongside the admin tab — the user
1392 * stays inside the desktop shell.
1393 *
1394 * Resolving the href against the document base gives the
1395 * parent an absolute URL it doesn't have to re-resolve.
1396 */
1397 e.preventDefault();
1398 var absolute;
1399 try {
1400 absolute = new URL( href, window.location.href ).toString();
1401 } catch ( err ) {
1402 return;
1403 }
1404 var label = ( link.textContent || '' ).trim() ||
1405 link.getAttribute( 'title' ) ||
1406 absolute;
1407 window.parent.postMessage(
1408 {
1409 type: 'desktop-mode-external-link',
1410 url: absolute,
1411 label: label.slice( 0, 80 )
1412 },
1413 window.location.origin
1414 );
1415 }
1416 }, true );
1417
1418 document.addEventListener( 'submit', function ( e ) {
1419 var form = e.target;
1420 if ( ! form || form.tagName !== 'FORM' ) {
1421 return;
1422 }
1423 var action = form.getAttribute( 'action' );
1424 var rewritten = rewriteAdminUrl( action || window.location.href, window.location.href );
1425 if ( rewritten ) {
1426 form.setAttribute( 'action', rewritten );
1427 }
1428 }, true );
1429
1430 /*
1431 * Focus-request bridge.
1432 *
1433 * Clicks inside an iframe don't cross the browsing-context
1434 * boundary — the parent shell's pointerdown / focusin listeners
1435 * never see them, so without this hook the only way to focus an
1436 * iframe window would be clicking its title bar chrome. Post a
1437 * `desktop-mode-focus-request` message on every pointerdown; the
1438 * parent Window class treats it as an onFocusRequest. Capture
1439 * phase so the signal fires before any stopPropagation inside
1440 * a page's own handlers.
1441 */
1442 document.addEventListener( 'pointerdown', function () {
1443 try {
1444 window.parent.postMessage(
1445 { type: 'desktop-mode-focus-request' },
1446 window.location.origin
1447 );
1448 } catch ( err ) {
1449 /* cross-origin parent (shouldn't happen for chromeless
1450 * pages, but don't let a throw break the bridge) */
1451 }
1452 }, true );
1453
1454 /*
1455 * OS-file drop forwarder. When the user drags a file from the
1456 * host OS into a chromeless admin iframe, intercept the drop
1457 * before the browser's default "navigate the iframe to the
1458 * file" handler fires, and `postMessage` the raw `File[]` up
1459 * to the parent shell so the OS-file drop manager
1460 * (`src/os-file-drop/manager.ts`) can show the upload dialog.
1461 *
1462 * Same-origin postMessage preserves `File` identity — the
1463 * parent receives real `File` objects, no base64 round-trip.
1464 *
1465 * We only intercept drops whose `DataTransfer.types` includes
1466 * `'Files'`. In-page DnD (Gutenberg block reorders, media
1467 * library drags) carries non-`Files` types and passes through
1468 * untouched.
1469 */
1470 function bridgeHasFiles( ev ) {
1471 var t = ev && ev.dataTransfer && ev.dataTransfer.types;
1472 if ( ! t ) {
1473 return false;
1474 }
1475 if ( typeof t.includes === 'function' ) {
1476 return t.includes( 'Files' );
1477 }
1478 if ( typeof t.contains === 'function' ) {
1479 return t.contains( 'Files' );
1480 }
1481 for ( var i = 0; i < t.length; i++ ) {
1482 if ( t[ i ] === 'Files' ) {
1483 return true;
1484 }
1485 }
1486 return false;
1487 }
1488 /*
1489 * Selectors of in-iframe drop receivers we leave alone —
1490 * Gutenberg's drop zone, the legacy media uploader, any
1491 * element a plugin marks with `data-drop-zone`. The whole
1492 * point: file drops onto Gutenberg blocks keep firing
1493 * Gutenberg's handler; only drops on the empty page
1494 * background escalate to the shell.
1495 */
1496 var bridgeDropPassthroughSelectors = [
1497 '.components-drop-zone',
1498 '[data-drop-zone]',
1499 '.uploader-window',
1500 '.media-frame-content'
1501 ];
1502 function bridgeDropTargetWantsFile( target ) {
1503 if ( ! target || ! target.closest ) {
1504 return false;
1505 }
1506 for ( var s = 0; s < bridgeDropPassthroughSelectors.length; s++ ) {
1507 if ( target.closest( bridgeDropPassthroughSelectors[ s ] ) ) {
1508 return true;
1509 }
1510 }
1511 return false;
1512 }
1513 /*
1514 * Bubble phase (not capture): the inner-most handler — Gutenberg's
1515 * drop zone, the legacy media uploader, or a third-party plugin
1516 * like "Administrador de archivos WP" — runs FIRST and gets the
1517 * chance to call `preventDefault()` to claim the drop. Our
1518 * forwarder then runs LAST at the document level and yields to
1519 * anyone who already took ownership.
1520 *
1521 * Two bail conditions, in order:
1522 * 1. `bridgeDropTargetWantsFile()` — the curated allowlist
1523 * (Gutenberg, wp.media, anything tagged `[data-drop-zone]`).
1524 * Kept as the primary check so the well-known core surfaces
1525 * behave identically to before, even if some edge case skips
1526 * the `preventDefault()` step.
1527 * 2. `ev.defaultPrevented` — the universal HTML5 contract: any
1528 * drop zone willing to receive a file calls `preventDefault()`
1529 * on `dragover` (mandatory per spec) and `drop` (to suppress
1530 * the browser's default navigate-to-file). When that's true,
1531 * some inner handler has taken the drop — yield so plugins
1532 * outside the allowlist (WP File Manager, Yoast, etc.) keep
1533 * their native UX.
1534 */
1535 document.addEventListener( 'dragover', function ( ev ) {
1536 if ( ! bridgeHasFiles( ev ) ) {
1537 return;
1538 }
1539 if ( bridgeDropTargetWantsFile( ev.target ) ) {
1540 return;
1541 }
1542 if ( ev.defaultPrevented ) {
1543 return;
1544 }
1545 ev.preventDefault();
1546 if ( ev.dataTransfer ) {
1547 ev.dataTransfer.dropEffect = 'copy';
1548 }
1549 }, false );
1550 document.addEventListener( 'drop', function ( ev ) {
1551 if ( ! bridgeHasFiles( ev ) ) {
1552 return;
1553 }
1554 if ( bridgeDropTargetWantsFile( ev.target ) ) {
1555 return;
1556 }
1557 if ( ev.defaultPrevented ) {
1558 return;
1559 }
1560 ev.preventDefault();
1561 ev.stopPropagation();
1562 var files = [];
1563 if ( ev.dataTransfer && ev.dataTransfer.files ) {
1564 for ( var i = 0; i < ev.dataTransfer.files.length; i++ ) {
1565 files.push( ev.dataTransfer.files[ i ] );
1566 }
1567 }
1568 if ( files.length === 0 ) {
1569 return;
1570 }
1571 try {
1572 window.parent.postMessage(
1573 {
1574 type: 'desktop-mode-os-file-drop',
1575 files: files,
1576 x: ev.clientX,
1577 y: ev.clientY,
1578 },
1579 window.location.origin
1580 );
1581 } catch ( err ) { /* cross-origin parent; swallow */ }
1582 }, false );
1583
1584 /*
1585 * Drag-hover forwarder. Native drag events don't cross iframe
1586 * boundaries, so when the user holds ANY drag (an OS file, an
1587 * image lifted off another admin page, a text selection) over
1588 * this window, the parent shell has no idea the window is being
1589 * hovered. Forward a throttled, payload-free heartbeat so the
1590 * shell's focus-on-drag-hover module
1591 * (`src/drag/focus-window-on-drag-hover.ts`) can raise this
1592 * window after its dwell. Purely observational — no
1593 * `preventDefault()`, no interference with in-page drop zones.
1594 * The parent identifies the hovered window from the message
1595 * source, so no coordinates travel.
1596 *
1597 * Sentinel-guarded: the standalone bridge bundle
1598 * (`iframe-bridge-standalone.ts`) installs the same forwarder,
1599 * and unlike the drop forwarder above there is no
1600 * `defaultPrevented` handshake to dedupe a double install.
1601 */
1602 if ( ! window.__desktopModeDragHoverForwarderInstalled ) {
1603 window.__desktopModeDragHoverForwarderInstalled = true;
1604 var dragHoverLastSent = 0;
1605 document.addEventListener( 'dragover', function ( ev ) {
1606 var now = Date.now();
1607 if ( now - dragHoverLastSent < 150 ) {
1608 return;
1609 }
1610 dragHoverLastSent = now;
1611 try {
1612 window.parent.postMessage(
1613 {
1614 type: 'desktop-mode-drag-hover',
1615 payloadType: bridgeHasFiles( ev ) ? 'os-file' : 'external',
1616 },
1617 window.location.origin
1618 );
1619 } catch ( err ) { /* cross-origin parent; swallow */ }
1620 }, true );
1621 }
1622
1623 /*
1624 * Cmd+K / Ctrl+K forwarder — single-press, unconditional.
1625 *
1626 * Native keydown events don't cross iframe boundaries. Inside a
1627 * chromeless admin page we want exactly ONE command palette: the
1628 * desktop shell's. WordPress's own `core/commands` palette is
1629 * harvested by `__wpdHarvestCommands` below and re-surfaced in the
1630 * shell palette, so there's no reason to ever let the in-page palette
1631 * take the keystroke.
1632 *
1633 * Capture phase + `stopImmediatePropagation` so we win the race
1634 * against Gutenberg / TinyMCE / plugin handlers bound to the same
1635 * shortcut. Shift/Alt modifiers pass through so user shortcuts using
1636 * those combos keep working.
1637 */
1638 document.addEventListener( 'keydown', function ( e ) {
1639 if ( ! ( e.metaKey || e.ctrlKey ) ) return;
1640 if ( e.key !== 'k' && e.key !== 'K' ) return;
1641 if ( e.shiftKey || e.altKey ) return;
1642
1643 e.preventDefault();
1644 e.stopImmediatePropagation();
1645
1646 try {
1647 window.parent.postMessage(
1648 { type: 'desktop-mode-palette-cycle' },
1649 window.location.origin
1650 );
1651 } catch ( err ) { /* cross-origin parent; swallow */ }
1652 }, true );
1653
1654 /*
1655 * Command harvester — bridges `wp.data.select('core/commands')` to
1656 * the parent shell.
1657 *
1658 * On `desktop-mode-commands-subscribe` from the parent, subscribe to
1659 * the `core/commands` store and post `desktop-mode-commands-list` on
1660 * every change (de-duplicated). On `desktop-mode-commands-invoke`, run
1661 * the original callback inside this iframe — the parent fires this
1662 * when the user selects a proxied command from the shell palette.
1663 *
1664 * Commands are classified by dry-invoking their callback inside a
1665 * `window.location`-intercept sandbox: pure-navigation callbacks
1666 * are flagged `navigate` (with the captured URL) so the parent can
1667 * open a new desktop window instead of navigating this iframe out
1668 * of chromeless mode. Everything else is `action` and proxies back
1669 * into this iframe on user selection.
1670 */
1671 var __wpdCommandsSubscribed = false;
1672 var __wpdCommandsLastPayload = '';
1673 var __wpdCommandsDebounceId = null;
1674 var __wpdCommandsOrigin = window.location.origin;
1675 // Cache per command name so the `window.location`-intercept
1676 // sandbox only runs once per command. Re-classifying on every
1677 // store tick would repeatedly fire side-effectful action
1678 // callbacks (preference toggles, modal opens) — unacceptable.
1679 // Keyed by name; value is the frozen classification minus the
1680 // live `label` / `icon` (which we always re-read in case the
1681 // command updated its own metadata).
1682 var __wpdCommandsKindCache = Object.create( null );
1683
1684 function __wpdRenderIconElement( icon ) {
1685 if ( ! icon ) return '';
1686 if ( typeof icon === 'string' ) return '';
1687 if ( ! window.wp || ! window.wp.element || typeof window.wp.element.renderToString !== 'function' ) {
1688 return '';
1689 }
1690 try {
1691 var rendered = window.wp.element.renderToString( icon );
1692 // `@wordpress/icons` entries render as a complete `<svg>`
1693 // tag. Anything else (wrapped components, empty fragments,
1694 // strings) falls back to dashicons in the palette — we only
1695 // accept markup we can inject straight into the icon slot.
1696 if ( typeof rendered === 'string' && rendered.toLowerCase().indexOf( '<svg' ) === 0 ) {
1697 return rendered;
1698 }
1699 } catch ( _err ) { /* swallow */ }
1700 return '';
1701 }
1702
1703 function __wpdClassifyCommand( cmd ) {
1704 // Defensive defaults — a broken registry should not tank the bridge.
1705 var out = {
1706 name: String( cmd && cmd.name ? cmd.name : '' ),
1707 label: String( cmd && cmd.label ? cmd.label : '' ),
1708 icon: cmd && cmd.icon && typeof cmd.icon === 'string' ? cmd.icon : undefined,
1709 iconSvg: undefined,
1710 context: cmd && cmd.context ? String( cmd.context ) : undefined,
1711 kind: 'action',
1712 url: undefined
1713 };
1714 if ( ! cmd || typeof cmd.callback !== 'function' ) {
1715 return out;
1716 }
1717
1718 // Short-circuit on cached classifications — `renderToString` on
1719 // the React icon is expensive, and the static URL regex scan
1720 // on `callback.toString()` is pure CPU we've already paid once.
1721 var cached = __wpdCommandsKindCache[ out.name ];
1722 if ( cached ) {
1723 out.kind = cached.kind;
1724 out.url = cached.url;
1725 out.iconSvg = cached.iconSvg;
1726 return out;
1727 }
1728
1729 // Render the React icon once per command name — Gutenberg
1730 // commands ship `icon` as a `@wordpress/icons` React element
1731 // the postMessage bridge can't serialize, so we flatten it to
1732 // a static SVG string here.
1733 if ( cmd.icon && typeof cmd.icon !== 'string' ) {
1734 out.iconSvg = __wpdRenderIconElement( cmd.icon );
1735 }
1736
1737 // STATIC classification — read the callback's source text and
1738 // look for a string-literal navigation target. We deliberately
1739 // do NOT execute the callback. An earlier iteration tried a
1740 // dry-run with a `window.location` intercept sandbox, but
1741 // `Location.prototype.href` is non-configurable: the shim
1742 // silently failed, every nav callback actually navigated the
1743 // iframe, the new page re-harvested, and the cascade opened
1744 // windows forever.
1745 //
1746 // Cases caught (WP's @wordpress/core-commands callbacks are
1747 // all of this shape):
1748 // document.location.href = 'url'
1749 // window.location.href = "url"
1750 // location.href = `url`
1751 // location.assign( 'url' )
1752 // location.replace( 'url' )
1753 //
1754 // Computed URLs (template-literal interpolation, addQueryArgs
1755 // calls, variables) fall back to `action` — the user picking
1756 // them will still run the real callback inside the iframe,
1757 // which is the safe default.
1758 var src = '';
1759 try { src = Function.prototype.toString.call( cmd.callback ); } catch ( _err ) { src = ''; }
1760 var navRe = /(?:document\.location\.href|window\.location\.href|location\.href)\s*=\s*['"]([^'"$]+?)['"]/;
1761 var asgRe = /location\.(?:assign|replace)\s*\(\s*['"]([^'"$]+?)['"]\s*\)/;
1762 var mm = src.match( navRe ) || src.match( asgRe );
1763 if ( mm && mm[ 1 ] ) {
1764 try {
1765 out.url = new URL( mm[ 1 ], window.location.href ).toString();
1766 out.kind = 'navigate';
1767 } catch ( _err ) {
1768 out.kind = 'action';
1769 }
1770 }
1771 __wpdCommandsKindCache[ out.name ] = { kind: out.kind, url: out.url, iconSvg: out.iconSvg };
1772 return out;
1773 }
1774
1775 // Harvested commands accumulate here. The React harvester writes
1776 // the full list each render; `__wpdPostCommandsList` reads + posts.
1777 var __wpdLastRawCommands = [];
1778 // Name → live `callback` reference. Loader-returned commands are
1779 // NOT in `wp.data.select('core/commands').getCommands()` — the
1780 // store only exposes statically-registered entries. Without a
1781 // private cache keyed off the React harvester's most recent render,
1782 // invoking a loader command from the parent palette ("Duplicate
1783 // block", "Transform to...", pattern commands) would silently fall
1784 // through to the `getCommands()` lookup and no-op.
1785 var __wpdCommandCallbacks = Object.create( null );
1786
1787 function __wpdFinalizeCommands( raw ) {
1788 var seen = Object.create( null );
1789 var out = [];
1790 var skipped = { missing: 0, disabled: 0, dup: 0 };
1791 for ( var i = 0; i < raw.length; i++ ) {
1792 var cmd = raw[ i ];
1793 if ( ! cmd || ! cmd.name || ! cmd.label ) { skipped.missing++; continue; }
1794 if ( cmd.disabled ) { skipped.disabled++; continue; }
1795 if ( seen[ cmd.name ] ) { skipped.dup++; continue; }
1796 seen[ cmd.name ] = true;
1797 out.push( __wpdClassifyCommand( cmd ) );
1798 }
1799 return out;
1800 }
1801
1802 function __wpdHarvestCommands() {
1803 return __wpdFinalizeCommands( __wpdLastRawCommands );
1804 }
1805
1806 // React-mounted harvester. Block-level / editor-contextual commands
1807 // (tier 3 loaders like `core/block-editor/selected-block-commands`,
1808 // `core/edit-post/pattern-commands`) are React *hooks* — they call
1809 // `useSelect` internally, which only works inside a function-
1810 // component render. So we mount an invisible React tree whose
1811 // children invoke each loader's hook at render time. On every
1812 // re-render (block selection changes, entity edits, welcome guide
1813 // toggled) the effect re-posts the fresh command list to the
1814 // parent. One component per loader keeps the rules-of-hooks
1815 // contract — the hook count inside each `LoaderSlot` is fixed at
1816 // one call (plus the constant `useEffect`), so React's reconciler
1817 // is happy.
1818 var __wpdReactMounted = false;
1819 // Stashed so `__wpdUnsubscribeCommands` can tear the harvester
1820 // down when focus leaves the window — otherwise the component
1821 // keeps re-rendering on every store tick, calling `mergeAndPost`,
1822 // and posting command lists the parent drops on the floor.
1823 var __wpdReactRoot = null;
1824 var __wpdReactHost = null;
1825
1826 function __wpdMountReactHarvester() {
1827 if ( __wpdReactMounted ) return;
1828 if ( ! window.wp || ! window.wp.element || ! window.wp.data ) {
1829 return;
1830 }
1831 var el = window.wp.element;
1832 var createEl = el.createElement;
1833 var useEffect = el.useEffect;
1834 var useRef = el.useRef;
1835 var useMemo = el.useMemo;
1836 var useSelect = ( window.wp.data && window.wp.data.useSelect ) || null;
1837 if ( ! createEl || ! useSelect || ! el.createRoot || ! useRef ) {
1838 return;
1839 }
1840 __wpdReactMounted = true;
1841
1842 // Hidden mount point. Positioned off-screen + `aria-hidden` so
1843 // nothing the harvester renders (it renders null anyway) can
1844 // leak into the accessibility tree or the visible document.
1845 var host = document.createElement( 'div' );
1846 host.setAttribute( 'aria-hidden', 'true' );
1847 host.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden;pointer-events:none;left:-9999px;top:-9999px;';
1848 ( document.body || document.documentElement ).appendChild( host );
1849 __wpdReactHost = host;
1850
1851 // Shared mutable bucket — ref-based aggregation to avoid the
1852 // classic setState-inside-useEffect loop. A `setState` here
1853 // would fire a parent re-render, which would fire the loader
1854 // hook again, which returns a fresh commands array with a new
1855 // reference even when the contents are identical, which would
1856 // re-fire the effect and setState again → Maximum update
1857 // depth exceeded. Refs don't trigger renders, so the loop is
1858 // broken even when hooks churn references.
1859 var resultsBucket = { perLoader: {}, statics: [], loadersList: [] };
1860
1861 function commandsFingerprint( cmds ) {
1862 if ( ! Array.isArray( cmds ) || cmds.length === 0 ) return '';
1863 // Cheap identity — name count is enough to decide whether
1864 // to re-post. Accepts some false negatives (two different
1865 // commands sharing a name) we'll never hit in practice.
1866 var keys = new Array( cmds.length );
1867 for ( var i = 0; i < cmds.length; i++ ) {
1868 var c = cmds[ i ];
1869 keys[ i ] = c && c.name ? c.name : '';
1870 }
1871 return keys.join( '|' );
1872 }
1873
1874 function mergeAndPost() {
1875 var merged = [];
1876 var loadersList = resultsBucket.loadersList;
1877 if ( Array.isArray( loadersList ) ) {
1878 for ( var i = 0; i < loadersList.length; i++ ) {
1879 var bucket = resultsBucket.perLoader[ loadersList[ i ] ];
1880 if ( Array.isArray( bucket ) ) merged = merged.concat( bucket );
1881 }
1882 }
1883 if ( Array.isArray( resultsBucket.statics ) ) {
1884 merged = merged.concat( resultsBucket.statics );
1885 }
1886 // Refresh the callback cache off the SAME snapshot we're
1887 // about to post. Loader-returned commands close over React
1888 // state (selected block, edited entity, etc.) that's only
1889 // valid for this render pass, so rebuilding from scratch
1890 // every merge keeps invoke-from-parent honest instead of
1891 // calling a stale closure.
1892 __wpdCommandCallbacks = Object.create( null );
1893 for ( var j = 0; j < merged.length; j++ ) {
1894 var cc = merged[ j ];
1895 if ( cc && cc.name && typeof cc.callback === 'function' ) {
1896 __wpdCommandCallbacks[ cc.name ] = cc.callback;
1897 }
1898 }
1899 __wpdLastRawCommands = merged;
1900 __wpdSchedulePost();
1901 }
1902
1903 // One slot per loader. Calls the loader's hook at render time;
1904 // an effect keyed on the commands' name-fingerprint writes the
1905 // fresh list into the shared bucket and posts. Ref-based, no
1906 // setState → no re-render cascade.
1907 function LoaderSlot( props ) {
1908 var loader = props.loader;
1909 var result = null;
1910 try {
1911 result = loader.hook( { search: '' } );
1912 } catch ( _err ) {
1913 /* swallow — a buggy loader hook shouldn't take the harvester down */
1914 }
1915 var cmds = ( result && Array.isArray( result.commands ) ) ? result.commands : [];
1916 var key = useMemo( function () { return commandsFingerprint( cmds ); }, [ cmds ] );
1917
1918 useEffect( function () {
1919 resultsBucket.perLoader[ loader.name ] = cmds;
1920 mergeAndPost();
1921 }, [ key ] );
1922
1923 useEffect( function () {
1924 return function () {
1925 delete resultsBucket.perLoader[ loader.name ];
1926 mergeAndPost();
1927 };
1928 }, [] );
1929
1930 return null;
1931 }
1932
1933 function Harvester() {
1934 var loaders = useSelect( function ( s ) {
1935 var ss = s( 'core/commands' );
1936 return ( ss && typeof ss.getCommandLoaders === 'function' )
1937 ? ss.getCommandLoaders( true )
1938 : [];
1939 }, [] );
1940 var staticCmds = useSelect( function ( s ) {
1941 var ss = s( 'core/commands' );
1942 return ( ss && typeof ss.getCommands === 'function' )
1943 ? ss.getCommands( true )
1944 : [];
1945 }, [] );
1946
1947 // Track the loader-name ordering so `mergeAndPost` can emit
1948 // tier-3 in a deterministic order (React reconciliation
1949 // order = registration order = the order the user sees).
1950 var loadersNames = useMemo( function () {
1951 if ( ! Array.isArray( loaders ) ) return [];
1952 return loaders.map( function ( l ) { return l ? l.name : ''; } );
1953 }, [ loaders ] );
1954 var loadersKey = loadersNames.join( '|' );
1955 useEffect( function () {
1956 resultsBucket.loadersList = loadersNames;
1957 mergeAndPost();
1958 }, [ loadersKey ] );
1959
1960 var staticKey = useMemo( function () { return commandsFingerprint( staticCmds ); }, [ staticCmds ] );
1961 useEffect( function () {
1962 resultsBucket.statics = Array.isArray( staticCmds ) ? staticCmds : [];
1963 mergeAndPost();
1964 }, [ staticKey ] );
1965
1966 if ( ! Array.isArray( loaders ) || loaders.length === 0 ) {
1967 return null;
1968 }
1969 var children = [];
1970 for ( var i = 0; i < loaders.length; i++ ) {
1971 var loader = loaders[ i ];
1972 if ( ! loader || typeof loader.hook !== 'function' ) continue;
1973 children.push( createEl( LoaderSlot, {
1974 key: loader.name,
1975 loader: loader
1976 } ) );
1977 }
1978 return createEl( el.Fragment || 'div', null, children );
1979 }
1980
1981 try {
1982 var root = el.createRoot( host );
1983 __wpdReactRoot = root;
1984 root.render( createEl( Harvester ) );
1985 } catch ( err ) {
1986 __wpdReactMounted = false;
1987 __wpdReactRoot = null;
1988 if ( __wpdReactHost && __wpdReactHost.parentNode ) {
1989 __wpdReactHost.parentNode.removeChild( __wpdReactHost );
1990 }
1991 __wpdReactHost = null;
1992 }
1993 }
1994
1995 function __wpdUnmountReactHarvester() {
1996 if ( __wpdReactRoot ) {
1997 try { __wpdReactRoot.unmount(); } catch ( _err ) { /* swallow */ }
1998 }
1999 __wpdReactRoot = null;
2000 if ( __wpdReactHost && __wpdReactHost.parentNode ) {
2001 __wpdReactHost.parentNode.removeChild( __wpdReactHost );
2002 }
2003 __wpdReactHost = null;
2004 __wpdReactMounted = false;
2005 __wpdLastRawCommands = [];
2006 __wpdCommandCallbacks = Object.create( null );
2007 }
2008
2009 function __wpdPostCommandsList() {
2010 var list = __wpdHarvestCommands();
2011 // Cheap de-dupe — the store fires on every unrelated preference
2012 // change too, and shipping an identical payload is pure noise.
2013 // Fingerprint on `name|kind|url` keeps us sensitive to the
2014 // visible surface (name changes, navigate-vs-action flips,
2015 // destination URL changes) while skipping `JSON.stringify` of
2016 // the entire payload — label/icon churn inside a single command
2017 // is rare and re-shipping on it is harmless noise vs. a hot
2018 // path allocation cost.
2019 var key = '';
2020 for ( var k = 0; k < list.length; k++ ) {
2021 var lc = list[ k ];
2022 key += ( lc && lc.name ? lc.name : '' ) + '|'
2023 + ( lc && lc.kind ? lc.kind : '' ) + '|'
2024 + ( lc && lc.url ? lc.url : '' ) + '\n';
2025 }
2026 if ( key === __wpdCommandsLastPayload ) {
2027 return;
2028 }
2029 __wpdCommandsLastPayload = key;
2030 try {
2031 window.parent.postMessage(
2032 { type: 'desktop-mode-commands-list', commands: list },
2033 __wpdCommandsOrigin
2034 );
2035 } catch ( _err ) {
2036 /* cross-origin parent (shouldn't happen for chromeless pages, but
2037 * don't let a throw break the bridge) */
2038 }
2039 }
2040
2041 function __wpdSchedulePost() {
2042 if ( __wpdCommandsDebounceId !== null ) return;
2043 __wpdCommandsDebounceId = window.setTimeout( function () {
2044 __wpdCommandsDebounceId = null;
2045 __wpdPostCommandsList();
2046 }, 60 );
2047 }
2048
2049 function __wpdSubscribeCommands() {
2050 __wpdCommandsSubscribed = true;
2051
2052 // If the React harvester is already running (focus left and
2053 // came back), the bucket still holds the latest merged list.
2054 // Reset the dedupe key so the next post actually ships, then
2055 // schedule it. The harvester itself won't re-fire its effects
2056 // just because the parent re-subscribed — React only reacts to
2057 // store changes, and the store hasn't changed. We have to
2058 // push from here.
2059 if ( __wpdReactMounted ) {
2060 __wpdCommandsLastPayload = '';
2061 __wpdSchedulePost();
2062 return;
2063 }
2064
2065 var attempts = 0;
2066 function tryBind() {
2067 if ( ! __wpdCommandsSubscribed ) return;
2068 if ( ! window.wp || ! window.wp.data || typeof window.wp.data.subscribe !== 'function' ) {
2069 if ( attempts++ < 40 ) {
2070 window.setTimeout( tryBind, 150 );
2071 }
2072 return;
2073 }
2074 // Mount the React harvester — tier 3 loaders are hooks and
2075 // need a legal render context to execute. On every re-render
2076 // the component's effect calls `__wpdSchedulePost` with the
2077 // fresh merged list, so we don't need a separate
2078 // `wp.data.subscribe` callback.
2079 __wpdMountReactHarvester();
2080 }
2081 tryBind();
2082 }
2083
2084 function __wpdUnsubscribeCommands() {
2085 __wpdCommandsSubscribed = false;
2086 __wpdCommandsLastPayload = '';
2087 if ( __wpdCommandsDebounceId !== null ) {
2088 try { window.clearTimeout( __wpdCommandsDebounceId ); } catch ( _err ) { /* swallow */ }
2089 __wpdCommandsDebounceId = null;
2090 }
2091 // Fully tear down the React harvester. Keeping it mounted in
2092 // the background wastes CPU: every store tick re-renders the
2093 // loader hooks, which rebuild the callback cache and post to
2094 // the parent (who drops the message because this window isn't
2095 // the subscribed one). On re-subscribe we remount from scratch.
2096 __wpdUnmountReactHarvester();
2097 }
2098
2099 function __wpdInvokeCommand( name ) {
2100 // Primary lookup — the React harvester's latest snapshot. This
2101 // covers loader-returned commands (Duplicate block, Transform
2102 // to, pattern commands) that never appear in the static
2103 // `getCommands()` list.
2104 var cb = __wpdCommandCallbacks[ name ];
2105 if ( typeof cb === 'function' ) {
2106 try {
2107 cb( { close: function () {} } );
2108 } catch ( _err ) {
2109 /* swallow — a plugin command callback that throws shouldn't break the bridge */
2110 }
2111 return;
2112 }
2113 // Fallback — statically registered commands that never passed
2114 // through the harvester (registered after the last render).
2115 if ( ! window.wp || ! window.wp.data ) {
2116 return;
2117 }
2118 var sel = null;
2119 try { sel = window.wp.data.select( 'core/commands' ); } catch ( _err ) { return; }
2120 if ( ! sel || typeof sel.getCommands !== 'function' ) return;
2121 var raw;
2122 try { raw = sel.getCommands(); } catch ( _err ) { return; }
2123 if ( ! raw ) return;
2124 for ( var i = 0; i < raw.length; i++ ) {
2125 if ( raw[ i ] && raw[ i ].name === name && typeof raw[ i ].callback === 'function' ) {
2126 try {
2127 raw[ i ].callback( { close: function () {} } );
2128 } catch ( _err ) {
2129 /* swallow — see note in primary path above */
2130 }
2131 return;
2132 }
2133 }
2134 }
2135
2136 // Attach the listener BEFORE the bridge-ready ping so a subscribe
2137 // posted synchronously in response is guaranteed to land.
2138 window.addEventListener( 'message', function ( e ) {
2139 if ( e.origin !== __wpdCommandsOrigin ) return;
2140 if ( ! e.data || typeof e.data.type !== 'string' ) return;
2141 if ( e.data.type === 'desktop-mode-commands-subscribe' ) {
2142 __wpdSubscribeCommands();
2143 } else if ( e.data.type === 'desktop-mode-commands-unsubscribe' ) {
2144 __wpdUnsubscribeCommands();
2145 } else if ( e.data.type === 'desktop-mode-commands-invoke' && typeof e.data.name === 'string' ) {
2146 __wpdInvokeCommand( e.data.name );
2147 }
2148 } );
2149
2150 // Handshake: tell the parent we're ready so it can (re)send any
2151 // subscribe that was dispatched before this listener attached.
2152 // Without this ping, a subscribe posted during iframe navigation
2153 // arrives at a context whose message listener isn't installed yet
2154 // and is silently dropped — the symptom is an empty palette even
2155 // though `wp.data.select('core/commands')` is perfectly happy.
2156 try {
2157 window.parent.postMessage(
2158 { type: 'desktop-mode-bridge-ready' },
2159 __wpdCommandsOrigin
2160 );
2161 } catch ( _err ) {
2162 /* parent gone or cross-origin — bridge handshake will retry on next load */
2163 }
2164
2165 /*
2166 * ` / Shift+` forwarder — window switcher.
2167 *
2168 * Bare backtick with no modifier. Must skip when focus is in a
2169 * text-entry element, otherwise typing ` into a block, a text
2170 * field, or TinyMCE would steal the keystroke. Non-text inputs
2171 * (checkbox, button, select) don't accept character input, so
2172 * cycling on those is fine.
2173 *
2174 * Same iframe-crossing rationale as the Cmd+K forwarder above:
2175 * native keydown doesn't reach the parent, so we postMessage.
2176 */
2177 document.addEventListener( 'keydown', function ( e ) {
2178 if ( e.ctrlKey || e.metaKey || e.altKey ) return;
2179 if ( e.code !== 'Backquote' ) return;
2180
2181 // IFRAME case catches Gutenberg: the block canvas is a nested
2182 // iframe, and Gutenberg re-dispatches cloned keydowns up to
2183 // this document for its shortcut system. Without this branch
2184 // typing ` in a block would cycle windows. Any other nested
2185 // iframe owning keyboard handling gets the same treatment.
2186 var el = document.activeElement;
2187 if ( el ) {
2188 var tag = el.tagName;
2189 if ( tag === 'IFRAME' ) return;
2190 if ( tag === 'TEXTAREA' ) return;
2191 if ( tag === 'INPUT' ) {
2192 var type = ( el.type || '' ).toLowerCase();
2193 var textTypes = [
2194 'text', 'search', 'url', 'email', 'password',
2195 'tel', 'number', 'date', 'datetime-local',
2196 'month', 'week', 'time'
2197 ];
2198 if ( textTypes.indexOf( type ) !== -1 ) return;
2199 }
2200 if ( el.isContentEditable ) return;
2201 }
2202
2203 e.preventDefault();
2204 e.stopImmediatePropagation();
2205
2206 try {
2207 window.parent.postMessage(
2208 {
2209 type: 'desktop-mode-window-switch',
2210 direction: e.shiftKey ? 'prev' : 'next'
2211 },
2212 window.location.origin
2213 );
2214 } catch ( err ) { /* cross-origin parent; swallow */ }
2215 }, true );
2216
2217 // Skip if the standalone iframe-bridge bundle already wired
2218 // screen-meta hoisting on this page. Two bridges racing to read
2219 // `aria-expanded` and reflect state would double-fire the
2220 // `desktop-mode-screen-meta-state` message and flicker the
2221 // title-bar buttons.
2222 if ( window.__desktopModeScreenMetaInstalled ) {
2223 return;
2224 }
2225 window.__desktopModeScreenMetaInstalled = true;
2226
2227 // Real screen options render form controls (column toggles, a
2228 // per-page input, custom settings). An empty wrap should not
2229 // surface a dead gear button.
2230 function hasScreenOptionsContent() {
2231 var wrap = document.getElementById( 'screen-options-wrap' );
2232 // WP always renders a nonce hidden input and an "Apply" submit
2233 // inside the wrap, so match only interactive option controls
2234 // (toggles, per-page, radios, selects) — never that always-
2235 // present scaffolding — or an empty panel reads as non-empty.
2236 return !! wrap && !! wrap.querySelector( 'input:not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]), select, textarea' );
2237 }
2238 // A help tab registered with empty content + no callback still
2239 // produces #contextual-help-link but an empty panel. Require some
2240 // non-whitespace tab/sidebar text before announcing the button.
2241 function hasHelpContent() {
2242 var wrap = document.getElementById( 'contextual-help-wrap' );
2243 if ( ! wrap ) {
2244 return false;
2245 }
2246 var panelEls = wrap.querySelectorAll( '.help-tab-content, .contextual-help-sidebar' );
2247 for ( var i = 0; i < panelEls.length; i++ ) {
2248 if ( ( panelEls[ i ].textContent || '' ).trim() !== '' ) {
2249 return true;
2250 }
2251 }
2252 return false;
2253 }
2254
2255 var links = document.getElementById( 'screen-meta-links' );
2256 var screenOptionsBtn = links ? document.getElementById( 'show-settings-link' ) : null;
2257 var helpBtn = links ? document.getElementById( 'contextual-help-link' ) : null;
2258 var panels = [];
2259 if ( screenOptionsBtn && hasScreenOptionsContent() ) {
2260 panels.push( 'screen-options' );
2261 }
2262 if ( helpBtn && hasHelpContent() ) {
2263 panels.push( 'help' );
2264 }
2265
2266 var origin = window.location.origin;
2267
2268 // ALWAYS announce — including an empty array — so the parent removes
2269 // stale gear/Help buttons when this page (e.g. after an in-place
2270 // same-slug navigation) has no screen meta. addScreenMetaButtons()
2271 // clears then repopulates, so an empty array removes everything.
2272 window.parent.postMessage( {
2273 type: 'desktop-mode-screen-meta',
2274 panels: panels
2275 }, origin );
2276
2277 if ( panels.length === 0 ) {
2278 return;
2279 }
2280
2281 function getOpenPanel() {
2282 if ( screenOptionsBtn && screenOptionsBtn.getAttribute( 'aria-expanded' ) === 'true' ) {
2283 return 'screen-options';
2284 }
2285 if ( helpBtn && helpBtn.getAttribute( 'aria-expanded' ) === 'true' ) {
2286 return 'help';
2287 }
2288 return null;
2289 }
2290
2291 function reportState() {
2292 window.parent.postMessage( {
2293 type: 'desktop-mode-screen-meta-state',
2294 open: getOpenPanel()
2295 }, origin );
2296 }
2297
2298 reportState();
2299
2300 var observer = new MutationObserver( reportState );
2301 if ( screenOptionsBtn ) {
2302 observer.observe( screenOptionsBtn, { attributes: true, attributeFilter: [ 'aria-expanded' ] } );
2303 }
2304 if ( helpBtn ) {
2305 observer.observe( helpBtn, { attributes: true, attributeFilter: [ 'aria-expanded' ] } );
2306 }
2307
2308 // WP's close() animates and shares #screen-meta between both panels,
2309 // so racing two animated clicks hides the panel that just opened.
2310 // Jump the other panel to its closed end state synchronously instead.
2311 function forceClose( button ) {
2312 if ( ! button || button.getAttribute( 'aria-expanded' ) !== 'true' ) {
2313 return;
2314 }
2315 var panelId = button.getAttribute( 'aria-controls' );
2316 var panel = panelId ? document.getElementById( panelId ) : null;
2317 if ( ! panel ) {
2318 return;
2319 }
2320 if ( window.jQuery ) {
2321 window.jQuery( panel ).stop( true, false );
2322 }
2323 panel.style.display = 'none';
2324 panel.classList.add( 'hidden' );
2325 if ( panel.parentNode instanceof HTMLElement ) {
2326 panel.parentNode.style.display = 'none';
2327 }
2328 button.classList.remove( 'screen-meta-active' );
2329 button.setAttribute( 'aria-expanded', 'false' );
2330 var toggles = document.querySelectorAll( '.screen-meta-toggle' );
2331 for ( var i = 0; i < toggles.length; i++ ) {
2332 toggles[ i ].style.visibility = '';
2333 }
2334 }
2335
2336 /* -----------------------------------------------------------------
2337 * Broadcast receiver — iframe side.
2338 *
2339 * The parent shell publishes broadcasts via
2340 * `wp.desktop.broadcast(topic, payload)` (see `src/broadcast.ts`).
2341 * It posts `{ type: 'desktop-mode-broadcast', topic, payload }` to
2342 * every open iframe. Here we re-dispatch that as a CustomEvent
2343 * on the iframe's own document so admin pages can subscribe with
2344 * plain `document.addEventListener( 'desktop-mode-broadcast', cb )`
2345 * — no extra script handle required.
2346 *
2347 * Iframe-side admin code can also publish UPSTREAM by posting
2348 * the same shape to `window.parent`; the parent's
2349 * `installBroadcastReceiver()` re-broadcasts to every other
2350 * iframe + native window.
2351 * ----------------------------------------------------------------- */
2352 window.addEventListener( 'message', function ( e ) {
2353 if ( e.origin !== origin ) {
2354 return;
2355 }
2356 if ( ! e.data || e.data.type !== 'desktop-mode-broadcast' ) {
2357 return;
2358 }
2359 try {
2360 document.dispatchEvent( new CustomEvent( 'desktop-mode-broadcast', {
2361 detail: { topic: e.data.topic, payload: e.data.payload }
2362 } ) );
2363 } catch ( _err ) { /* old browser without CustomEvent ctor — ignore */ }
2364 } );
2365
2366 /* -----------------------------------------------------------------
2367 * Soft-reload — iframe-side default handler.
2368 *
2369 * When a `desktop-mode.<post_type>.changed` broadcast fires AND the
2370 * current iframe is on a known list page for that post type, we
2371 * fetch the current URL and replace the iframe's `#wpbody-content`
2372 * in place. The user sees the new state of the list — restored
2373 * post appears, deleted media disappears — without the WP loading
2374 * spinner that `location.reload()` would show.
2375 *
2376 * Single-edit pages (`post.php`, `post-new.php`, the HPOS order
2377 * editor) are deliberately NOT matched: replacing their body would
2378 * destroy any unsaved Gutenberg/classic-editor state. Plugins that
2379 * want specific behaviour for those pages can subscribe to the
2380 * same topic on `document` and handle it themselves.
2381 *
2382 * Matching is generic: the current page's "list type" is derived
2383 * from the URL (`edit.php` → its `post_type` param or `post`,
2384 * `upload.php` → `attachment`, `edit-comments.php` → `comment`)
2385 * and compared against the `<type>` captured from any
2386 * `desktop-mode.<type>.changed` topic — so every custom post
2387 * type's `edit.php?post_type=X` screen participates with zero
2388 * per-type code. Non-`edit.php` list screens (e.g. WooCommerce's
2389 * HPOS `admin.php?page=wc-orders`) are covered by declarative
2390 * extra rules, filterable server-side via
2391 * `desktop_mode_soft_reload_rules`.
2392 *
2393 * The fetch carries a custom header so a later phase can serve a
2394 * minimal partial response if we want to optimise; for now WP
2395 * returns the full admin page and we just pluck the body.
2396 *
2397 * WP list-table JS uses event delegation on `document`/`body`,
2398 * which survives `replaceWith`. If a specific page breaks after
2399 * a swap (e.g. inline-edit double-binding), that page's plugin
2400 * should listen for `desktop-mode-soft-reloaded` and rebind.
2401 * ----------------------------------------------------------------- */
2402 var DESKTOP_MODE_SOFT_RELOAD_EXTRAS = /*__DESKTOP_MODE_SOFT_RELOAD_EXTRAS__*/;
2403
2404 function _desktop_modeEndsWith( s, suffix ) { return s.lastIndexOf( suffix ) === s.length - suffix.length; }
2405
2406 function _desktop_modeListType() {
2407 if ( _desktop_modeEndsWith( location.pathname, '/wp-admin/edit.php' ) ) {
2408 return new URLSearchParams( location.search ).get( 'post_type' ) || 'post';
2409 }
2410 if ( _desktop_modeEndsWith( location.pathname, '/wp-admin/upload.php' ) ) {
2411 return 'attachment';
2412 }
2413 if ( _desktop_modeEndsWith( location.pathname, '/wp-admin/edit-comments.php' ) ) {
2414 return 'comment';
2415 }
2416 if ( _desktop_modeEndsWith( location.pathname, '/wp-admin/plugins.php' ) ) {
2417 return 'plugin';
2418 }
2419 // plugin-install.php is intentionally not a soft-reload target.
2420 // Reloading that page mid-session would discard the user's search
2421 // results or reset an in-progress install. The page still emits
2422 // plugin.changed (via notifyPluginInstall below); it just doesn't
2423 // reload itself in response to one.
2424 return null;
2425 }
2426
2427 function _desktop_modeMatchesExtraRule( rule ) {
2428 if ( ! rule || ! rule.path ) {
2429 return false;
2430 }
2431 if ( ! _desktop_modeEndsWith( location.pathname, '/wp-admin/' + rule.path ) ) {
2432 return false;
2433 }
2434 var params = new URLSearchParams( location.search );
2435 if ( rule.query ) {
2436 for ( var key in rule.query ) {
2437 if ( ! Object.prototype.hasOwnProperty.call( rule.query, key ) ) {
2438 continue;
2439 }
2440 if ( params.get( key ) !== String( rule.query[ key ] ) ) {
2441 return false;
2442 }
2443 }
2444 }
2445 if ( rule.queryAbsent ) {
2446 for ( var i = 0; i < rule.queryAbsent.length; i++ ) {
2447 if ( params.has( rule.queryAbsent[ i ] ) ) {
2448 return false;
2449 }
2450 }
2451 }
2452 return true;
2453 }
2454
2455 function _desktop_modeSoftReloadTopicMatches( topic ) {
2456 var m = /^desktop-mode\.(.+)\.changed$/.exec( topic );
2457 if ( m && m[ 1 ] === _desktop_modeListType() ) {
2458 return true;
2459 }
2460 for ( var i = 0; i < DESKTOP_MODE_SOFT_RELOAD_EXTRAS.length; i++ ) {
2461 var rule = DESKTOP_MODE_SOFT_RELOAD_EXTRAS[ i ];
2462 if ( rule && rule.topic === topic && _desktop_modeMatchesExtraRule( rule ) ) {
2463 return true;
2464 }
2465 }
2466 return false;
2467 }
2468
2469 var _desktop_modeSoftReloadInFlight = false;
2470 var _desktop_modeSoftReloadQueued = false;
2471
2472 function _desktop_modeSoftReload() {
2473 if ( _desktop_modeSoftReloadInFlight ) {
2474 _desktop_modeSoftReloadQueued = true;
2475 return;
2476 }
2477 _desktop_modeSoftReloadInFlight = true;
2478 fetch( location.href, {
2479 credentials: 'same-origin',
2480 cache: 'no-cache',
2481 headers: { 'X-WP-Desktop-Soft-Reload': '1' }
2482 } ).then( function ( r ) {
2483 if ( ! r.ok ) throw new Error( 'soft-reload fetch failed: ' + r.status );
2484 return r.text();
2485 } ).then( function ( html ) {
2486 var doc = new DOMParser().parseFromString( html, 'text/html' );
2487 var fresh = doc.querySelector( '#wpbody-content' );
2488 var live = document.querySelector( '#wpbody-content' );
2489 if ( ! fresh || ! live ) {
2490 /* Markup we expected isn't there — admin pages we
2491 * don't recognise (or core changes the structure).
2492 * Don't reload; let the iframe stay as it is rather
2493 * than show a spinner the user told us not to. */
2494 return;
2495 }
2496 live.replaceWith( fresh );
2497 try {
2498 document.dispatchEvent( new CustomEvent( 'desktop-mode-soft-reloaded' ) );
2499 } catch ( _err ) {}
2500 /* Some WP scripts re-init on DOMContentLoaded only — let
2501 * pages opt-in to a re-init by listening to the event
2502 * above. We intentionally do NOT re-fire DOMContentLoaded;
2503 * that's almost always wrong (double-init of jQuery/WP). */
2504 } ).catch( function ( err ) {
2505 /* Network error — leave the iframe untouched. The user's
2506 * next manual interaction will refresh state, and the
2507 * next broadcast will retry. */
2508 if ( window.console && window.console.warn ) {
2509 window.console.warn( '[desktop-mode] soft-reload skipped:', err );
2510 }
2511 } ).then( function () {
2512 _desktop_modeSoftReloadInFlight = false;
2513 if ( _desktop_modeSoftReloadQueued ) {
2514 _desktop_modeSoftReloadQueued = false;
2515 _desktop_modeSoftReload();
2516 }
2517 } );
2518 }
2519
2520 document.addEventListener( 'desktop-mode-broadcast', function ( e ) {
2521 var detail = e.detail || {};
2522 var topic = detail.topic;
2523 if ( ! topic ) return;
2524 if ( _desktop_modeSoftReloadTopicMatches( topic ) ) {
2525 _desktop_modeSoftReload();
2526 }
2527 } );
2528
2529 window.addEventListener( 'message', function( e ) {
2530 if ( e.origin !== origin ) {
2531 return;
2532 }
2533 if ( ! e.data || e.data.type !== 'desktop-mode-toggle-panel' ) {
2534 return;
2535 }
2536 var target = null;
2537 if ( e.data.panel === 'screen-options' && screenOptionsBtn ) {
2538 target = screenOptionsBtn;
2539 } else if ( e.data.panel === 'help' && helpBtn ) {
2540 target = helpBtn;
2541 }
2542 if ( ! target ) {
2543 return;
2544 }
2545 if ( target.getAttribute( 'aria-expanded' ) !== 'true' ) {
2546 var other = target === screenOptionsBtn ? helpBtn : screenOptionsBtn;
2547 forceClose( other );
2548 }
2549 target.click();
2550 } );
2551
2552 /* -----------------------------------------------------------------
2553 * Connection bridge — iframe side.
2554 *
2555 * Plugins call `wp.desktop.iframe.publish(topic, payload)` /
2556 * `subscribe(topic, cb)` / `onConnection(cb)` to talk to a parent-
2557 * side `wp.desktop.connect()` caller. The shell only routes;
2558 * topic semantics are plugin-defined.
2559 *
2560 * Connections are tracked locally so `onConnection` can fire when
2561 * the parent opens a new channel (typical use: start emitting
2562 * heavy events only after at least one consumer subscribed). Each
2563 * connection carries a topic-allowlist negotiated at handshake
2564 * time — wildcard ('*') subscribers see everything.
2565 * ----------------------------------------------------------------- */
2566 var _wpdConnections = {};
2567 var _wpdConnectionListeners = [];
2568 var _wpdSubs = {}; // topic → [cb, ...]
2569 var _wpdChannelSubs = {}; // channel → [cb, ...] (window-channel API)
2570 var _wpdParentOrigin = window.location.origin;
2571 var _wpdWindowId = null; // host window id, from the handshake
2572 var _wpdWindowIdWaiters = []; // pending whenWindowId() resolvers
2573
2574 /* Stash the host window's id (the parent's handshake carries
2575 * `targetWindowId`) and flush any `whenWindowId()` waiters. Same
2576 * contract as `assets/js/iframe-bridge.js`. */
2577 function _wpdSetWindowId( id ) {
2578 if ( ! id || _wpdWindowId === id ) {
2579 return;
2580 }
2581 _wpdWindowId = id;
2582 var waiters = _wpdWindowIdWaiters.splice( 0 );
2583 for ( var i = 0; i < waiters.length; i++ ) {
2584 try {
2585 waiters[ i ]( id );
2586 } catch ( _err ) { /* swallow */ }
2587 }
2588 }
2589
2590 function _wpdEmitToParent( connectionId, topic, payload ) {
2591 try {
2592 window.parent.postMessage( {
2593 type: 'desktop-mode-bridge-publish',
2594 connectionId: connectionId,
2595 topic: topic,
2596 payload: payload
2597 }, _wpdParentOrigin );
2598 } catch ( _err ) { /* parent gone */ }
2599 }
2600
2601 window.addEventListener( 'message', function ( ev ) {
2602 if ( ev.origin !== _wpdParentOrigin ) {
2603 return;
2604 }
2605 var data = ev && ev.data;
2606 if ( ! data || typeof data !== 'object' || typeof data.type !== 'string' ) {
2607 return;
2608 }
2609
2610 if ( data.type === 'desktop-mode-bridge-beforeunload-query' ) {
2611 var prevent = false;
2612 var msg = '';
2613
2614 function shimReturnValue( ev ) {
2615 Object.defineProperty( ev, 'returnValue', {
2616 get: function() { return this._returnValue || ''; },
2617 set: function( v ) { this._returnValue = v; }
2618 } );
2619 }
2620
2621 function checkPrevent( ev, result ) {
2622 var hasRes = typeof result === 'string' && result !== '';
2623 var hasRetVal = typeof ev.returnValue === 'string' && ev.returnValue !== '';
2624 if ( ev.defaultPrevented || hasRes || hasRetVal ) {
2625 prevent = true;
2626 if ( hasRes ) {
2627 msg = result;
2628 } else if ( hasRetVal ) {
2629 msg = ev.returnValue;
2630 }
2631 }
2632 }
2633
2634 var unloadEvent;
2635 try {
2636 unloadEvent = new Event( 'beforeunload', { cancelable: true } );
2637 } catch ( _err ) {
2638 unloadEvent = document.createEvent( 'Event' );
2639 unloadEvent.initEvent( 'beforeunload', false, true );
2640 }
2641 shimReturnValue( unloadEvent );
2642
2643 if ( typeof window.onbeforeunload === 'function' ) {
2644 var res = window.onbeforeunload( unloadEvent );
2645 checkPrevent( unloadEvent, res );
2646 }
2647 if ( ! prevent ) {
2648 var dispatchEvent;
2649 try {
2650 dispatchEvent = new Event( 'beforeunload', { cancelable: true } );
2651 } catch ( _err ) {
2652 dispatchEvent = document.createEvent( 'Event' );
2653 dispatchEvent.initEvent( 'beforeunload', false, true );
2654 }
2655 shimReturnValue( dispatchEvent );
2656 window.dispatchEvent( dispatchEvent );
2657 checkPrevent( dispatchEvent, null );
2658 }
2659
2660 try {
2661 window.parent.postMessage( {
2662 type: 'desktop-mode-bridge-beforeunload-response',
2663 prevent: prevent,
2664 message: msg
2665 }, _wpdParentOrigin );
2666 } catch ( _err ) { /* swallow */ }
2667 return;
2668 }
2669
2670 if ( data.type === 'desktop-mode-bridge-handshake' && typeof data.connectionId === 'string' ) {
2671 /* The parent's handshake carries the host window id —
2672 * stash it so `wp.desktop.iframe.windowId` and
2673 * `whenWindowId()` can serve callers that need to know
2674 * which native window opened this iframe. */
2675 if ( typeof data.targetWindowId === 'string' && data.targetWindowId !== '' ) {
2676 _wpdSetWindowId( data.targetWindowId );
2677 }
2678 if ( _wpdConnections[ data.connectionId ] ) {
2679 /* Re-handshake on iframe-ready re-arm — no-op besides
2680 * acking again so the parent can resume. */
2681 try {
2682 window.parent.postMessage( {
2683 type: 'desktop-mode-bridge-handshake-ack',
2684 connectionId: data.connectionId
2685 }, _wpdParentOrigin );
2686 } catch ( _err ) { /* swallow */ }
2687 return;
2688 }
2689 var conn = {
2690 id: data.connectionId,
2691 topics: Array.isArray( data.topics ) ? data.topics.slice() : []
2692 };
2693 _wpdConnections[ conn.id ] = conn;
2694 try {
2695 window.parent.postMessage( {
2696 type: 'desktop-mode-bridge-handshake-ack',
2697 connectionId: conn.id
2698 }, _wpdParentOrigin );
2699 } catch ( _err ) { /* swallow */ }
2700 for ( var i = 0; i < _wpdConnectionListeners.length; i++ ) {
2701 try {
2702 _wpdConnectionListeners[ i ]( {
2703 id: conn.id,
2704 topics: conn.topics.slice()
2705 } );
2706 } catch ( _err ) { /* swallow listener */ }
2707 }
2708 return;
2709 }
2710
2711 if ( data.type === 'desktop-mode-bridge-publish' && typeof data.topic === 'string' ) {
2712 var bucket = _wpdSubs[ data.topic ];
2713 if ( bucket ) {
2714 for ( var j = 0; j < bucket.length; j++ ) {
2715 try {
2716 bucket[ j ]( data.payload, { topic: data.topic, connectionId: data.connectionId } );
2717 } catch ( _err ) { /* swallow subscriber */ }
2718 }
2719 }
2720 var wildcard = _wpdSubs[ '*' ];
2721 if ( wildcard ) {
2722 for ( var k = 0; k < wildcard.length; k++ ) {
2723 try {
2724 wildcard[ k ]( data.payload, { topic: data.topic, connectionId: data.connectionId } );
2725 } catch ( _err ) { /* swallow */ }
2726 }
2727 }
2728 return;
2729 }
2730
2731 if ( data.type === 'desktop-mode-bridge-disconnect' && typeof data.connectionId === 'string' ) {
2732 delete _wpdConnections[ data.connectionId ];
2733 return;
2734 }
2735
2736 /* Unified window-channel delivery from the parent. Fires
2737 * every `wp.desktop.on( channel, cb )` subscriber for the
2738 * matching channel — same protocol as
2739 * `assets/js/iframe-bridge.js`. */
2740 if ( data.type === 'desktop-mode-window-send' && typeof data.channel === 'string' && data.channel !== '' ) {
2741 var meta = { channel: data.channel };
2742 var cBucket = _wpdChannelSubs[ data.channel ];
2743 if ( cBucket ) {
2744 var cBucketSnap = cBucket.slice();
2745 for ( var ci = 0; ci < cBucketSnap.length; ci++ ) {
2746 try {
2747 cBucketSnap[ ci ]( data.payload, meta );
2748 } catch ( _err ) { /* swallow */ }
2749 }
2750 }
2751 var cWildcard = _wpdChannelSubs[ '*' ];
2752 if ( cWildcard ) {
2753 var cWildcardSnap = cWildcard.slice();
2754 for ( var cw = 0; cw < cWildcardSnap.length; cw++ ) {
2755 try {
2756 cWildcardSnap[ cw ]( data.payload, meta );
2757 } catch ( _err ) { /* swallow */ }
2758 }
2759 }
2760 return;
2761 }
2762 } );
2763
2764 var iframeApi = {
2765 /**
2766 * Publish a payload under a topic. Sent to every connection
2767 * — typical case is one connection per parent caller, but
2768 * a debug console may have several at once.
2769 */
2770 publish: function ( topic, payload ) {
2771 if ( typeof topic !== 'string' || topic === '' ) {
2772 return;
2773 }
2774 var ids = Object.keys( _wpdConnections );
2775 for ( var i = 0; i < ids.length; i++ ) {
2776 _wpdEmitToParent( ids[ i ], topic, payload );
2777 }
2778 },
2779 /**
2780 * Subscribe to a topic. Returns an unsubscribe function.
2781 * Use `'*'` to receive every published payload (debugging).
2782 */
2783 subscribe: function ( topic, cb ) {
2784 if ( typeof topic !== 'string' || topic === '' || typeof cb !== 'function' ) {
2785 return function () {};
2786 }
2787 var bucket = _wpdSubs[ topic ];
2788 if ( ! bucket ) {
2789 bucket = [];
2790 _wpdSubs[ topic ] = bucket;
2791 }
2792 bucket.push( cb );
2793 return function () {
2794 var i = bucket.indexOf( cb );
2795 if ( i >= 0 ) {
2796 bucket.splice( i, 1 );
2797 }
2798 };
2799 },
2800 /**
2801 * Notified whenever a parent caller opens a connection. Use
2802 * to start emitting heavy publish events only when somebody
2803 * is listening.
2804 */
2805 onConnection: function ( cb ) {
2806 if ( typeof cb !== 'function' ) {
2807 return function () {};
2808 }
2809 _wpdConnectionListeners.push( cb );
2810 /* Replay current connections — late subscribers still
2811 * see who's already there. */
2812 var ids = Object.keys( _wpdConnections );
2813 for ( var i = 0; i < ids.length; i++ ) {
2814 try {
2815 cb( {
2816 id: _wpdConnections[ ids[ i ] ].id,
2817 topics: _wpdConnections[ ids[ i ] ].topics.slice()
2818 } );
2819 } catch ( _err ) { /* swallow */ }
2820 }
2821 return function () {
2822 var i = _wpdConnectionListeners.indexOf( cb );
2823 if ( i >= 0 ) {
2824 _wpdConnectionListeners.splice( i, 1 );
2825 }
2826 };
2827 },
2828 /**
2829 * Iframe-initiated connection request. See
2830 * `assets/js/iframe-bridge.js` — same shape, same protocol.
2831 */
2832 requestConnection: function ( opts ) {
2833 opts = opts || {};
2834 var topics = Array.isArray( opts.topics ) ? opts.topics.slice() : [];
2835 var requestId = 'wpdir-' + Math.random().toString( 36 ).slice( 2, 10 );
2836
2837 return new Promise( function ( resolve, reject ) {
2838 var settled = false;
2839 var timeoutMs = typeof opts.timeoutMs === 'number'
2840 ? opts.timeoutMs
2841 : 5000;
2842
2843 function settle( ok, value ) {
2844 if ( settled ) {
2845 return;
2846 }
2847 settled = true;
2848 window.removeEventListener( 'message', onAck );
2849 clearTimeout( timer );
2850 if ( ok ) {
2851 resolve( value );
2852 } else {
2853 reject( value );
2854 }
2855 }
2856
2857 function onAck( ev ) {
2858 if ( ev.origin !== _wpdParentOrigin ) {
2859 return;
2860 }
2861 var d = ev && ev.data;
2862 if (
2863 ! d ||
2864 typeof d !== 'object' ||
2865 d.type !== 'desktop-mode-bridge-connection-ack' ||
2866 d.requestId !== requestId
2867 ) {
2868 return;
2869 }
2870 if ( d.accepted ) {
2871 var summary = {
2872 id: typeof d.connectionId === 'string' ? d.connectionId : '',
2873 topics: topics.slice()
2874 };
2875 if ( typeof opts.onOpen === 'function' ) {
2876 try { opts.onOpen( summary ); } catch ( _err ) { /* swallow */ }
2877 }
2878 settle( true, summary );
2879 } else {
2880 settle( false, new Error( d.reason || 'rejected' ) );
2881 }
2882 }
2883 window.addEventListener( 'message', onAck );
2884
2885 var timer = setTimeout( function () {
2886 settle( false, new Error( 'timeout' ) );
2887 }, timeoutMs );
2888
2889 try {
2890 window.parent.postMessage( {
2891 type: 'desktop-mode-bridge-connection-request',
2892 requestId: requestId,
2893 topics: topics
2894 }, _wpdParentOrigin );
2895 } catch ( err ) {
2896 settle( false, err );
2897 }
2898 } );
2899 },
2900 /**
2901 * Window-chrome helpers. See `assets/js/iframe-bridge.js` —
2902 * same shape, same protocol. `setSlot` is HTML-only
2903 * (sandboxed via `textContent` on the parent side).
2904 */
2905 chrome: {
2906 setTheme: function ( tokens ) {
2907 try {
2908 window.parent.postMessage( {
2909 type: 'desktop-mode-chrome-theme',
2910 tokens: tokens || {}
2911 }, _wpdParentOrigin );
2912 } catch ( _err ) { /* parent gone */ }
2913 },
2914 setControls: function ( config ) {
2915 try {
2916 window.parent.postMessage( {
2917 type: 'desktop-mode-chrome-controls',
2918 config: config === undefined ? null : config
2919 }, _wpdParentOrigin );
2920 } catch ( _err ) { /* parent gone */ }
2921 },
2922 setSlot: function ( name, html ) {
2923 if ( typeof name !== 'string' || name === '' ) {
2924 return;
2925 }
2926 try {
2927 window.parent.postMessage( {
2928 type: 'desktop-mode-chrome-slot',
2929 slot: name,
2930 html: typeof html === 'string' ? html : ''
2931 }, _wpdParentOrigin );
2932 } catch ( _err ) { /* parent gone */ }
2933 }
2934 },
2935 /**
2936 * The id of the window the parent shell opened to host this
2937 * iframe. Populated by the first connection handshake (the
2938 * parent's handshake carries `targetWindowId`). `null` until
2939 * then.
2940 */
2941 get windowId() {
2942 return _wpdWindowId;
2943 },
2944 /**
2945 * Resolve once `windowId` is populated by the first handshake.
2946 * Resolves immediately if already known. Never rejects — guard
2947 * with `isParentReachable()` first.
2948 */
2949 whenWindowId: function () {
2950 if ( _wpdWindowId !== null ) {
2951 return Promise.resolve( _wpdWindowId );
2952 }
2953 return new Promise( function ( resolve ) {
2954 _wpdWindowIdWaiters.push( resolve );
2955 } );
2956 },
2957 /**
2958 * Whether the parent frame is same-origin and reachable. All
2959 * bridge messages hard-filter on origin — a cross-origin
2960 * parent silently drops everything we post. Use this predicate
2961 * to fail fast instead of debugging vanishing messages.
2962 */
2963 isParentReachable: function () {
2964 if ( ! window.parent || window.parent === window ) {
2965 return false;
2966 }
2967 try {
2968 /* Cross-origin parents throw on `.location.origin`
2969 * access; same-origin parents return a string we can
2970 * compare to our own origin. */
2971 return window.parent.location.origin === _wpdParentOrigin;
2972 } catch ( _err ) {
2973 return false;
2974 }
2975 }
2976 };
2977
2978 if ( ! window.wp ) { window.wp = {}; }
2979 if ( ! window.wp.desktop ) { window.wp.desktop = {}; }
2980 window.wp.desktop.iframe = iframeApi;
2981
2982 /* Unified window-channel API. Mirror of the equivalent block
2983 * in `assets/js/iframe-bridge.js` — keep both in sync. The
2984 * parent shell posts `desktop-mode-window-send` on
2985 * `Window.send( channel, payload )`; iframe-side handlers
2986 * register via `wp.desktop.on( channel, cb )`. Sending the
2987 * other way (`wp.desktop.send`) posts up to the parent where
2988 * `Window.on( channel, cb )` subscribers fire. */
2989 if ( typeof window.wp.desktop.send !== 'function' ) {
2990 window.wp.desktop.send = function ( channel, payload ) {
2991 if ( typeof channel !== 'string' || channel === '' ) {
2992 return;
2993 }
2994 try {
2995 window.parent.postMessage( {
2996 type: 'desktop-mode-window-publish',
2997 channel: channel,
2998 payload: payload
2999 }, _wpdParentOrigin );
3000 } catch ( _err ) { /* parent gone */ }
3001 };
3002 }
3003 if ( typeof window.wp.desktop.on !== 'function' ) {
3004 window.wp.desktop.on = function ( channel, cb ) {
3005 if ( typeof channel !== 'string' || channel === '' || typeof cb !== 'function' ) {
3006 return function () {};
3007 }
3008 var bucket = _wpdChannelSubs[ channel ];
3009 if ( ! bucket ) {
3010 bucket = [];
3011 _wpdChannelSubs[ channel ] = bucket;
3012 }
3013 bucket.push( cb );
3014 return function () {
3015 var i = bucket.indexOf( cb );
3016 if ( i >= 0 ) {
3017 bucket.splice( i, 1 );
3018 }
3019 };
3020 };
3021 }
3022
3023 /* -----------------------------------------------------------------
3024 * Stale-nonce recovery after a session-expiry re-login.
3025 *
3026 * When the user's session expires while a chromeless window is
3027 * open, this iframe does NOT show core's `wp-auth-check` login
3028 * modal — `desktop_mode_chromeless_suppress_auth_check()` keeps
3029 * the modal assets out of chromeless requests so the parent
3030 * shell owns the single prompt for the whole desktop. Detection
3031 * still works without the modal JS: core attaches the
3032 * `wp-auth-check` boolean to every heartbeat response
3033 * server-side, and this iframe's own heartbeat keeps ticking.
3034 *
3035 * After re-auth the auth cookie is fresh — but every per-page
3036 * nonce cached in JS globals (`_wpUpdatesSettings.ajax_nonce`,
3037 * `commonL10n.nonce`, Gutenberg's `wpApiSettings.nonce`, etc.)
3038 * was minted under the OLD session and is now rejected by
3039 * `check_ajax_referer`. WP reports that as "Cookie check
3040 * failed" on the next plugin Install / Activate / Update click,
3041 * which is misleading: the cookie is fine; the nonce is stale.
3042 *
3043 * Fix: watch jQuery's `heartbeat-tick`. If we ever see
3044 * `wp-auth-check: false` and then later see the same field flip
3045 * back to `true`, the user re-authed mid-session and every
3046 * cached nonce in this iframe is stale — reload so they
3047 * regenerate from the fresh session. The parent is nudged
3048 * first (`desktop-mode-reauth-detected`) so its own recovery
3049 * (`src/auth-recovery/index.ts`: in-place nonce refresh + a
3050 * reload sweep over sibling iframes that haven't ticked yet)
3051 * starts immediately instead of waiting for the parent's
3052 * heartbeat schedule.
3053 *
3054 * If jQuery never loads on this page (rare — most admin screens
3055 * pull it for heartbeat already), this block is a no-op.
3056 * ----------------------------------------------------------------- */
3057 ( function _wpdInstallAuthCheckRecovery() {
3058 var attached = false;
3059 var sawLoggedOut = false;
3060 function attach() {
3061 if ( attached || ! window.jQuery ) {
3062 return;
3063 }
3064 attached = true;
3065 window.jQuery( document ).on( 'heartbeat-tick.wpdAuthRecover', function ( ev, data ) {
3066 if ( ! data || typeof data !== 'object' || ! ( 'wp-auth-check' in data ) ) {
3067 return;
3068 }
3069 if ( data[ 'wp-auth-check' ] === false ) {
3070 sawLoggedOut = true;
3071 return;
3072 }
3073 if ( sawLoggedOut && data[ 'wp-auth-check' ] === true ) {
3074 sawLoggedOut = false;
3075 // Tell the parent shell BEFORE we reload so it
3076 // doesn't have to wait for its own heartbeat
3077 // tick (up to 60s on an idle shell) to discover
3078 // the cookie is fresh. Parent runs its full
3079 // recovery path on receipt — overlay teardown,
3080 // iframe reload sweep, then a hard reload.
3081 try {
3082 if ( window.parent && window.parent !== window ) {
3083 window.parent.postMessage(
3084 { type: 'desktop-mode-reauth-detected' },
3085 window.location.origin
3086 );
3087 }
3088 } catch ( _err ) { /* parent gone */ }
3089 try { window.location.reload(); } catch ( _err ) { /* swallow */ }
3090 }
3091 } );
3092 }
3093 attach();
3094 if ( document.readyState === 'loading' ) {
3095 document.addEventListener( 'DOMContentLoaded', attach, { once: true } );
3096 }
3097 window.addEventListener( 'load', attach, { once: true } );
3098 } )();
3099
3100 /* -----------------------------------------------------------------
3101 * Shiny-update watcher (GH#296).
3102 *
3103 * Core's updates.js applies plugin/theme updates and deletes over
3104 * AJAX — no navigation, so the load-time payload emit above never
3105 * re-fires and the shell's update notifiers (admin-bar circle-arrows
3106 * count, dock Plugins badge) keep showing the pre-update numbers
3107 * until a hard refresh. Watch the jQuery events updates.js triggers
3108 * on `document` after each job and nudge the shell to spend one
3109 * `refreshMenu()` probe, whose payload carries fresh counts.
3110 *
3111 * Error events are included deliberately: `wp_ajax_update_plugin`
3112 * calls `wp_update_plugins()` up front, which can mutate the
3113 * update transient even when the upgrade itself fails.
3114 *
3115 * When updates.js is processing a queue (bulk-selected shiny
3116 * updates), per-job events fire while later jobs are still
3117 * pending — skip those and let the final job's event send the one
3118 * nudge. The shell debounces on its side too, so this is purely
3119 * an optimization, not a correctness gate.
3120 *
3121 * If jQuery never loads on this page this block is a no-op — and
3122 * so is updates.js, which requires it.
3123 * ----------------------------------------------------------------- */
3124 ( function _wpdInstallShinyUpdateWatcher() {
3125 var attached = false;
3126 function notify() {
3127 try {
3128 var queue = window.wp && window.wp.updates && window.wp.updates.queue;
3129 if ( queue && queue.length > 0 ) {
3130 return;
3131 }
3132 } catch ( _err ) { /* queue introspection is best-effort */ }
3133 try {
3134 var shell = window.top || window.parent;
3135 if ( shell && shell !== window ) {
3136 shell.postMessage(
3137 { type: 'desktop-mode-updates-changed' },
3138 window.location.origin
3139 );
3140 }
3141 } catch ( _err ) { /* shell gone or cross-origin */ }
3142 }
3143 function notifyPluginInstall() {
3144 // `wp-plugin-install-success` fires after an AJAX install on
3145 // plugin-install.php with no page navigation. The PHP
3146 // `upgrader_process_complete` hook records the change correctly,
3147 // but `desktop_mode_content_changes_emit_footer` only runs on
3148 // chromeless page requests — admin-ajax.php is not in the
3149 // chromeless allowlist, so there's no in-band emit from that
3150 // request. The Heartbeat buffer will eventually deliver it, but
3151 // posting directly here lets the Installed tab refresh
3152 // immediately. The later Heartbeat tick will produce a second
3153 // broadcast; consumers handle no-op refreshes gracefully.
3154 try {
3155 var shell = window.top || window.parent;
3156 if ( shell && shell !== window ) {
3157 shell.postMessage(
3158 {
3159 type: 'desktop-mode-broadcast',
3160 topic: 'desktop-mode.plugin.changed',
3161 payload: { source: 'chromeless-bridge', action: 'install' }
3162 },
3163 window.location.origin
3164 );
3165 }
3166 } catch ( _err ) { /* shell gone or cross-origin */ }
3167 }
3168 function attach() {
3169 if ( attached || ! window.jQuery ) {
3170 return;
3171 }
3172 attached = true;
3173 window.jQuery( document ).on(
3174 [
3175 'wp-plugin-update-success.wpdUpdates',
3176 'wp-plugin-update-error.wpdUpdates',
3177 'wp-plugin-delete-success.wpdUpdates',
3178 'wp-theme-update-success.wpdUpdates',
3179 'wp-theme-update-error.wpdUpdates',
3180 'wp-theme-delete-success.wpdUpdates'
3181 ].join( ' ' ),
3182 notify
3183 );
3184 window.jQuery( document ).on( 'wp-plugin-install-success.wpdUpdates', notifyPluginInstall );
3185 }
3186 attach();
3187 if ( document.readyState === 'loading' ) {
3188 document.addEventListener( 'DOMContentLoaded', attach, { once: true } );
3189 }
3190 window.addEventListener( 'load', attach, { once: true } );
3191 } )();
3192
3193 /*
3194 * Bridge-ready signal. Every listener installed by this script
3195 * is now wired; let the parent shell know so it can fire
3196 * `HOOKS.IFRAME_READY` and re-arm any connection handshakes
3197 * (`src/connection/index.ts#onIframeReady`) that arrived before
3198 * we were listening. Without this, every consumer of
3199 * `HOOKS.IFRAME_READY` (devtools replay, connection rearm)
3200 * stays silent for the lifetime of the iframe — documented
3201 * surface that never actually fires.
3202 *
3203 * Posted to the parent's own origin only. Wrapped in try/catch
3204 * because cross-origin parents (top-level admin opened outside
3205 * the shell) would throw on the postMessage and we don't want a
3206 * single failed dispatch to wedge anything else above.
3207 */
3208 try {
3209 if ( window.parent && window.parent !== window ) {
3210 window.parent.postMessage(
3211 { type: 'desktop-mode-ready' },
3212 window.location.origin
3213 );
3214 }
3215 } catch ( _err ) { /* parent gone or cross-origin */ }
3216 } )();
3217 JS;
3218
3219 // On pages that don't carry a full payload, ship the lightweight
3220 // menu signature so the shell can detect an off-allowlist menu
3221 // change (e.g. a CPT registered via a settings tool) and refresh
3222 // only then. The full payload already embeds its own `menuSig`, so
3223 // there's no point recomputing it when one is being sent. GH#325.
3224 $menu_sig_json = 'null';
3225 if ( 'null' === $menu_payload_json ) {
3226 $menu_sig = desktop_mode_menu_signature();
3227 if ( '' !== $menu_sig ) {
3228 $encoded_sig = wp_json_encode( $menu_sig );
3229 if ( false !== $encoded_sig ) {
3230 $menu_sig_json = $encoded_sig;
3231 }
3232 }
3233 }
3234
3235 // Declarative soft-reload rules for list screens that are NOT a
3236 // standard `edit.php?post_type=<type>` / `upload.php` /
3237 // `edit-comments.php` page (those are matched generically in the
3238 // bridge script). Rule shape:
3239 // - `topic` — the `desktop-mode.<type>.changed` topic.
3240 // - `path` — wp-admin filename (`admin.php`).
3241 // - `query` — required query params (exact match).
3242 // - `queryAbsent` — params that must NOT be present.
3243 //
3244 // The default rule covers WooCommerce's HPOS orders list.
3245 // `queryAbsent: [ 'action' ]` is load-bearing: with `&action=edit`
3246 // the same path is the single-order EDITOR, which must keep the
3247 // single-edit exclusion (a soft reload would destroy unsaved
3248 // order state). Shipped unconditionally — when WooCommerce is
3249 // absent the URL never renders and the rule is inert.
3250 $soft_reload_rules = array(
3251 array(
3252 'topic' => 'desktop-mode.shop_order.changed',
3253 'path' => 'admin.php',
3254 'query' => array( 'page' => 'wc-orders' ),
3255 'queryAbsent' => array( 'action' ),
3256 ),
3257 );
3258
3259 /**
3260 * Filters the declarative soft-reload rules injected into every
3261 * chromeless iframe.
3262 *
3263 * Lets a plugin whose list screen lives on a custom admin URL
3264 * participate in cross-window refresh: pair a rule here with
3265 * `desktop_mode_content_changes_record()` calls (or your own
3266 * `desktop-mode.<type>.changed` broadcasts) on the publish side.
3267 *
3268 * @since 0.9.7
3269 *
3270 * @param array $soft_reload_rules Rule arrays with keys `topic`,
3271 * `path`, `query`, `queryAbsent`.
3272 */
3273 $soft_reload_rules = (array) apply_filters( 'desktop_mode_soft_reload_rules', $soft_reload_rules );
3274 $soft_reload_json = wp_json_encode( array_values( $soft_reload_rules ) );
3275 if ( ! $soft_reload_json ) {
3276 $soft_reload_json = '[]';
3277 }
3278
3279 // Substitute the server-built menu payload into the bridge
3280 // script. `wp_json_encode` guarantees safe JSON output — no need
3281 // for an additional escape pass. When the page isn't on our
3282 // menu-altering allowlist the placeholder resolves to `null` and
3283 // the bridge skips the postMessage.
3284 $js = str_replace( '/*__DESKTOP_MODE_MENU_PAYLOAD__*/', $menu_payload_json, $js );
3285 $js = str_replace( '/*__DESKTOP_MODE_MENU_SIG__*/', $menu_sig_json, $js );
3286 $js = str_replace( '/*__DESKTOP_MODE_CONTENT_IDENTITY__*/', $content_identity_json, $js );
3287 $js = str_replace( '/*__DESKTOP_MODE_SOFT_RELOAD_EXTRAS__*/', $soft_reload_json, $js );
3288
3289 wp_print_inline_script_tag( $js );
3290 }
3291 add_action( 'admin_footer', 'desktop_mode_chromeless_bridge_script' );
3292