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