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

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