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

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

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