| 1 |
<?php |
| 2 |
/** |
| 3 |
* OpenStation — Chromeless iframe bridge. |
| 4 |
* |
| 5 |
* Three 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_navigation_ping_script()` — runs on |
| 15 |
* `admin_head @ 1` and tells the shell a navigation has landed. |
| 16 |
* |
| 17 |
* - `openstation_chromeless_bridge_script()` — runs on |
| 18 |
* `admin_footer` and emits the chromeless ↔ shell bridge |
| 19 |
* script that handles screen-meta detection, command-palette |
| 20 |
* harvesting, plugin-changed payloads, etc. The biggest |
| 21 |
* hook in the original render.php (~1,950 LOC) — the bulk is |
| 22 |
* the inline JS string the iframe runs. |
| 23 |
* |
| 24 |
* Extracted from `render.php` during the architecture-0.8.1 PHP |
| 25 |
* slicing (phase 6). |
| 26 |
* |
| 27 |
* @package OpenStation |
| 28 |
*/ |
| 29 |
|
| 30 |
defined( 'ABSPATH' ) || exit; |
| 31 |
|
| 32 |
|
| 33 |
/** |
| 34 |
* Neutralizes hardcoded admin-bar offsets on positioned elements |
| 35 |
* inside chromeless iframes. |
| 36 |
* |
| 37 |
* Many plugins compile their CSS with the admin-bar height baked in |
| 38 |
* as a literal pixel value rather than referencing |
| 39 |
* `var(--wp-admin--admin-bar--height)`. WooCommerce's |
| 40 |
* `.woocommerce-layout__header` is the canonical case — it ships as |
| 41 |
* `top: 32px` (or `46px` on small screens) because the SCSS source |
| 42 |
* uses build-time interpolation (`#{$header-height + $adminbar-height-mobile}`). |
| 43 |
* A CSS-variable rebind cannot reach these rules because the rules |
| 44 |
* never read the variable. |
| 45 |
* |
| 46 |
* The only generic mitigation is a runtime DOM pass: |
| 47 |
* |
| 48 |
* 1. Walk every positioned element (`fixed | sticky | absolute`). |
| 49 |
* 2. Compare its computed `top` against the set of values that |
| 50 |
* reserve admin-bar height (defaults: `32px`, `46px`). |
| 51 |
* 3. If it matches, override `top` to `0` inline with `!important`. |
| 52 |
* |
| 53 |
* The match is exact-pixel — we deliberately don't catch e.g. |
| 54 |
* `top: 33px` (which is almost certainly intentional and unrelated |
| 55 |
* to admin-bar geometry). False positives are possible but |
| 56 |
* unlikely; a plugin would have to use `top: 32px` for a reason |
| 57 |
* unrelated to the admin bar AND need that exact value to remain |
| 58 |
* inside chromeless. We've never seen one in the wild, and if a |
| 59 |
* site hits it, the filter below lets them narrow the scan. |
| 60 |
* |
| 61 |
* Scoped via the `os-chromeless` body class. Runs ONE |
| 62 |
* full walk at DOMContentLoaded, then watches for late additions |
| 63 |
* with a `MutationObserver` so React-mounted components are |
| 64 |
* corrected as they appear instead of via a second full-DOM walk |
| 65 |
* at `load`. The observer only inspects added nodes, not the |
| 66 |
* whole document. |
| 67 |
* |
| 68 |
* The observer callback itself does NO style reads: it only |
| 69 |
* enqueues added elements and schedules one idle flush |
| 70 |
* (`requestIdleCallback`, 500 ms timeout backstop; plain |
| 71 |
* `setTimeout` fallback). Every `getComputedStyle()` read forces |
| 72 |
* a synchronous style recalculation, and a MutationObserver |
| 73 |
* callback runs as a microtask BEFORE the next paint — so reading |
| 74 |
* computed style in the callback puts a forced style flush on the |
| 75 |
* exact path Gutenberg hammers hardest while the user types |
| 76 |
* (block toolbar mounts, popovers, autocompleters; hundreds of |
| 77 |
* descendants per batch). Deferring the walk to idle time takes |
| 78 |
* the neutralizer off the typing path entirely; a late-mounted |
| 79 |
* plugin header is corrected a frame or two later, which is |
| 80 |
* imperceptible for elements that only just appeared. |
| 81 |
* |
| 82 |
* Fallback for very old browsers without `MutationObserver`: |
| 83 |
* keep the second walk at `load`. The current minimum (IE 11+) |
| 84 |
* already ships MO, so the fallback only fires on extreme |
| 85 |
* outliers — but it's free insurance. |
| 86 |
*/ |
| 87 |
function openstation_chromeless_offset_neutralizer_script() { |
| 88 |
if ( ! openstation_is_chromeless_request() ) { |
| 89 |
return; |
| 90 |
} |
| 91 |
|
| 92 |
/** |
| 93 |
* Filters the set of `top` pixel values that mark a positioned |
| 94 |
* element as an admin-bar offset clone. |
| 95 |
* |
| 96 |
* Defaults match the two admin-bar heights Core ships: `32px` |
| 97 |
* for desktop, `46px` for the mobile breakpoint. Sites that |
| 98 |
* customize the admin bar height (some accessibility themes |
| 99 |
* raise it to 50px) can extend the list. |
| 100 |
* |
| 101 |
* @param string[] $values Default `[ '32px', '46px' ]`. |
| 102 |
*/ |
| 103 |
$top_values = apply_filters( |
| 104 |
'openstation_chromeless_admin_bar_top_values', |
| 105 |
array( '32px', '46px' ) |
| 106 |
); |
| 107 |
|
| 108 |
$config = wp_json_encode( |
| 109 |
array( |
| 110 |
'tops' => array_values( array_filter( array_map( 'strval', (array) $top_values ) ) ), |
| 111 |
) |
| 112 |
); |
| 113 |
if ( false === $config ) { |
| 114 |
return; |
| 115 |
} |
| 116 |
|
| 117 |
// Build the inline JS as a concatenated single-quoted string — |
| 118 |
// Plugin Check disallows heredoc syntax (PluginCheck.CodeAnalysis. |
| 119 |
// Heredoc.NotAllowed), so the source is uglier than the original |
| 120 |
// `<<<JS … JS;` block but functionally identical. The trailing |
| 121 |
// `$config` JSON is appended at the end so the whole body is a |
| 122 |
// closure receiving a `{tops: [...]}` argument. |
| 123 |
$js = '(function(C){'; |
| 124 |
$js .= 'var TOPS={};'; |
| 125 |
$js .= 'for(var t=0;t<C.tops.length;t++){TOPS[C.tops[t]]=1;}'; |
| 126 |
$js .= 'function fixOne(el){'; |
| 127 |
$js .= 'if(!el||el.nodeType!==1)return;'; |
| 128 |
$js .= 'var cs;'; |
| 129 |
$js .= 'try{cs=getComputedStyle(el);}catch(_e){return;}'; |
| 130 |
$js .= "if(cs.position==='static')return;"; |
| 131 |
$js .= "if(TOPS[cs.top]){el.style.setProperty('top','0px','important');}"; |
| 132 |
$js .= '}'; |
| 133 |
$js .= 'function walkSubtree(root){'; |
| 134 |
$js .= 'if(!root)return;'; |
| 135 |
$js .= 'if(root.nodeType===1){fixOne(root);}'; |
| 136 |
$js .= "var els=root.querySelectorAll?root.querySelectorAll('*'):[];"; |
| 137 |
$js .= 'for(var i=0;i<els.length;i++){fixOne(els[i]);}'; |
| 138 |
$js .= '}'; |
| 139 |
// Added nodes are queued and walked in ONE idle-time flush. The |
| 140 |
// observer callback must never read computed style itself — it |
| 141 |
// runs before the next paint, so a style read there is a forced |
| 142 |
// synchronous recalc on the editor's typing path. |
| 143 |
$js .= 'var queue=[];'; |
| 144 |
$js .= 'var scheduled=false;'; |
| 145 |
$js .= 'function flush(){'; |
| 146 |
$js .= 'scheduled=false;'; |
| 147 |
$js .= 'var batch=queue;'; |
| 148 |
$js .= 'queue=[];'; |
| 149 |
$js .= 'for(var i=0;i<batch.length;i++){'; |
| 150 |
// Skip nodes detached between enqueue and flush (transient |
| 151 |
// popovers, React unmounts) — nothing visible to correct, and |
| 152 |
// getComputedStyle on a detached tree is wasted work. |
| 153 |
$js .= 'if(batch[i].isConnected===false)continue;'; |
| 154 |
$js .= 'walkSubtree(batch[i]);'; |
| 155 |
$js .= '}'; |
| 156 |
$js .= '}'; |
| 157 |
$js .= 'function schedule(){'; |
| 158 |
$js .= 'if(scheduled)return;'; |
| 159 |
$js .= 'scheduled=true;'; |
| 160 |
$js .= 'if(window.requestIdleCallback){window.requestIdleCallback(flush,{timeout:500});}'; |
| 161 |
$js .= 'else{window.setTimeout(flush,200);}'; |
| 162 |
$js .= '}'; |
| 163 |
$js .= 'var started=false;'; |
| 164 |
$js .= 'function start(){'; |
| 165 |
$js .= 'if(started)return;'; |
| 166 |
$js .= "if(!document.body||!document.body.classList.contains('os-chromeless'))return;"; |
| 167 |
$js .= 'started=true;'; |
| 168 |
$js .= 'var MO=window.MutationObserver;'; |
| 169 |
$js .= 'if(MO){'; |
| 170 |
$js .= 'var observer=new MO(function(records){'; |
| 171 |
$js .= 'var found=false;'; |
| 172 |
$js .= 'for(var r=0;r<records.length;r++){'; |
| 173 |
$js .= 'var rec=records[r];'; |
| 174 |
$js .= "if(rec.type!=='childList')continue;"; |
| 175 |
$js .= 'var added=rec.addedNodes;'; |
| 176 |
$js .= 'for(var n=0;n<added.length;n++){'; |
| 177 |
// Element nodes only — rich-text edits insert text nodes by the |
| 178 |
// dozen, and those can never carry a positioned offset. |
| 179 |
$js .= 'if(added[n].nodeType===1){queue.push(added[n]);found=true;}'; |
| 180 |
$js .= '}'; |
| 181 |
$js .= '}'; |
| 182 |
$js .= 'if(found){schedule();}'; |
| 183 |
$js .= '});'; |
| 184 |
$js .= 'observer.observe(document.body,{childList:true,subtree:true});'; |
| 185 |
$js .= '}'; |
| 186 |
$js .= 'walkSubtree(document.body);'; |
| 187 |
// Defense in depth — pre-MutationObserver browsers fall back to the |
| 188 |
// original double-walk so React-mounted components added between |
| 189 |
// DOMContentLoaded and load still get neutralized. |
| 190 |
$js .= 'if(!MO){'; |
| 191 |
$js .= "window.addEventListener('load',function(){walkSubtree(document.body);},{once:true});"; |
| 192 |
$js .= '}'; |
| 193 |
$js .= '}'; |
| 194 |
$js .= "if(document.readyState==='loading'){"; |
| 195 |
$js .= "document.addEventListener('DOMContentLoaded',start,{once:true});"; |
| 196 |
$js .= '}else{'; |
| 197 |
$js .= 'start();'; |
| 198 |
$js .= '}'; |
| 199 |
$js .= '})(' . $config . ');'; |
| 200 |
|
| 201 |
wp_print_inline_script_tag( $js ); |
| 202 |
} |
| 203 |
add_action( 'admin_head', 'openstation_chromeless_offset_neutralizer_script', 1 ); |
| 204 |
|
| 205 |
/** |
| 206 |
* Tells the shell that a navigation has landed, for the status ring: |
| 207 |
* a submit's "end" can only come from the document answering it, and |
| 208 |
* the bridge below is the wrong messenger. Enqueued on `admin_footer`, |
| 209 |
* it runs after every other admin script — a second or more after the |
| 210 |
* browser painted the "Settings saved." notice the ring is |
| 211 |
* confirming. From the head it beats the body to the screen. |
| 212 |
* |
| 213 |
* The parent ignores it unless that window has a submit waiting. |
| 214 |
*/ |
| 215 |
function openstation_chromeless_navigation_ping_script() { |
| 216 |
if ( ! openstation_is_chromeless_request() ) { |
| 217 |
return; |
| 218 |
} |
| 219 |
|
| 220 |
wp_print_inline_script_tag( |
| 221 |
"try{if(window.parent&&window.parent!==window){window.parent.postMessage({type:'os-iframe-navigated'},window.location.origin);}}catch(e){}" |
| 222 |
); |
| 223 |
} |
| 224 |
add_action( 'admin_head', 'openstation_chromeless_navigation_ping_script', 1 ); |
| 225 |
|
| 226 |
/** |
| 227 |
* Short-circuit `admin.php?openstation_menu_refresh=1` requests with |
| 228 |
* a tiny inline-script response that postMessages the current menu |
| 229 |
* payload to the parent shell. |
| 230 |
* |
| 231 |
* The full chromeless bridge is hooked on `admin_footer`, which Core |
| 232 |
* only fires from `admin-header.php` / `admin-footer.php`. Plain |
| 233 |
* `admin.php` without `?page=` (or one of the other dispatch paths |
| 234 |
* in admin.php) never includes the footer — the file just runs the |
| 235 |
* `load-{$pagenow}` hook in the `else` branch and exits. The full |
| 236 |
* bridge therefore never emits its payload, and the parent's |
| 237 |
* `wp.os.refreshMenu()` waits out its 8-second timeout for a |
| 238 |
* message that's never coming. That's the source of "deactivating a |
| 239 |
* plugin leaves its dock icons behind" — the hidden probe iframe |
| 240 |
* the shell spawns to harvest the post-mutation menu lands on a |
| 241 |
* page that doesn't fire admin_footer. |
| 242 |
* |
| 243 |
* Hooking here on `admin_init @ 99` runs AFTER `wp-admin/menu.php` |
| 244 |
* has loaded (which fires `admin_menu` and populates `$menu`) but |
| 245 |
* BEFORE admin.php's per-page dispatch. We can emit the payload |
| 246 |
* straight away and short-circuit the rest of admin.php so the probe |
| 247 |
* resolves in milliseconds instead of timing out. |
| 248 |
* |
| 249 |
* No admin-header / admin-footer means no `#adminmenu` DOM, so the |
| 250 |
* full bridge's CSS-icon harvest doesn't run here. That's an |
| 251 |
* acceptable trade-off: items whose icons live in `$menu[$i][6]` |
| 252 |
* (the vast majority) still ship correctly; items that rely on a |
| 253 |
* CSS `::before` on `#adminmenu .menu-icon-<slug>` fall back to the |
| 254 |
* default gear icon on a live refresh until the next full page load |
| 255 |
* — strictly better than today's "dock doesn't update at all." |
| 256 |
*/ |
| 257 |
function openstation_emit_menu_refresh_probe() { |
| 258 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only payload harvest; capability-gated by chromeless gate below. |
| 259 |
if ( empty( $_GET['openstation_menu_refresh'] ) ) { |
| 260 |
return; |
| 261 |
} |
| 262 |
if ( ! openstation_is_chromeless_request() ) { |
| 263 |
return; |
| 264 |
} |
| 265 |
// Only short-circuit the bare `admin.php` probe — for any real |
| 266 |
// admin page (plugins.php, edit.php, etc.) we still want the full |
| 267 |
// admin-footer-hosted bridge to fire so the icon harvest runs. |
| 268 |
$pagenow = isset( $GLOBALS['pagenow'] ) ? (string) $GLOBALS['pagenow'] : ''; |
| 269 |
if ( 'admin.php' !== $pagenow ) { |
| 270 |
return; |
| 271 |
} |
| 272 |
|
| 273 |
$payload = openstation_menu_refresh_probe_payload(); |
| 274 |
$encoded = wp_json_encode( $payload ); |
| 275 |
if ( false === $encoded ) { |
| 276 |
return; |
| 277 |
} |
| 278 |
|
| 279 |
nocache_headers(); |
| 280 |
header( 'Content-Type: text/html; charset=utf-8' ); |
| 281 |
|
| 282 |
// Mirror the full bridge's message shape so the same shell-side |
| 283 |
// listener consumes both. |
| 284 |
echo '<!doctype html><html><head><meta charset="utf-8"><title></title></head><body>'; |
| 285 |
echo '<script>'; |
| 286 |
echo '(function(){try{if(window.parent&&window.parent!==window){window.parent.postMessage({type:"os-plugins-changed",payload:'; |
| 287 |
echo $encoded; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode produces JSON-safe output. |
| 288 |
echo '},window.location.origin);}}catch(e){}})();'; |
| 289 |
echo '</script>'; |
| 290 |
echo '</body></html>'; |
| 291 |
exit; |
| 292 |
} |
| 293 |
add_action( 'admin_init', 'openstation_emit_menu_refresh_probe', 99 ); |
| 294 |
|
| 295 |
/** |
| 296 |
* Build the menu payload the refresh probe emits, with the script |
| 297 |
* data the harvest depends on attached first. |
| 298 |
* |
| 299 |
* `openstation_build_menu_payload()` harvests every lazy native |
| 300 |
* window's handle-attached data — `wp_localize_script` blobs and |
| 301 |
* `wp_add_inline_script` snippets — via |
| 302 |
* `openstation_resolve_script_payload()`. Modules attach that data on |
| 303 |
* `admin_enqueue_scripts` at priority ≤ 5 (the contract |
| 304 |
* `Tests_OpenStation_LazyWindowConfigPriority` pins), which holds on |
| 305 |
* every payload producer except this one: the probe short-circuits |
| 306 |
* `admin.php` on `admin_init`, long before Core would fire the |
| 307 |
* enqueue hook, so nothing was ever attached and the harvested |
| 308 |
* entries shipped with empty `scriptBefore` / `scriptL10n` arrays. |
| 309 |
* |
| 310 |
* The shell refreshes its native-window index from every payload it |
| 311 |
* receives, so one probe response silently downgraded windows the |
| 312 |
* boot payload had delivered complete — the first lazy open of WP |
| 313 |
* Explorer after a menu refresh found the WooCommerce companion with |
| 314 |
* no `openStationWooConfig`, and the store's order bands and preview |
| 315 |
* panels went dark with nothing in the console to say why. |
| 316 |
* |
| 317 |
* Replaying the hook here makes the probe's request faithful to the |
| 318 |
* real admin page it stands in for. The output buffer guards the |
| 319 |
* short-circuit response: an enqueue callback that echoes must not |
| 320 |
* beat our `header()` calls. Enqueued handles are never printed — |
| 321 |
* the probe exits before any print pipeline runs. |
| 322 |
* |
| 323 |
* @return array Menu payload, same shape as `openstation_build_menu_payload()`. |
| 324 |
*/ |
| 325 |
function openstation_menu_refresh_probe_payload() { |
| 326 |
if ( ! did_action( 'admin_enqueue_scripts' ) ) { |
| 327 |
// `admin.php` calls `set_current_screen()` AFTER `admin_init`, |
| 328 |
// so at probe time there is no screen yet — and Core's own |
| 329 |
// enqueue callbacks (the block-editor script loader among |
| 330 |
// them) read `get_current_screen()->id` unguarded. Build the |
| 331 |
// screen the real flow would have had before the hook fires, |
| 332 |
// in the request's own admin context — see |
| 333 |
// `openstation_menu_refresh_probe_screen_id()`. |
| 334 |
if ( function_exists( 'set_current_screen' ) && function_exists( 'get_current_screen' ) && ! get_current_screen() ) { |
| 335 |
set_current_screen( openstation_menu_refresh_probe_screen_id() ); |
| 336 |
} |
| 337 |
ob_start(); |
| 338 |
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- deliberate replay of Core's own hook so handle-attached script data exists before the harvest below. |
| 339 |
do_action( 'admin_enqueue_scripts', 'admin.php' ); |
| 340 |
ob_end_clean(); |
| 341 |
} |
| 342 |
|
| 343 |
return openstation_build_menu_payload(); |
| 344 |
} |
| 345 |
|
| 346 |
/** |
| 347 |
* The screen id the refresh probe builds its placeholder `WP_Screen` |
| 348 |
* from: `admin-network` in the network admin, `admin-user` in the user |
| 349 |
* admin, `admin` on a site. |
| 350 |
* |
| 351 |
* Once a screen exists, `is_network_admin()` answers from it — and a |
| 352 |
* screen built from a bare id gets its admin context from the id's |
| 353 |
* SUFFIX (`-network`, `-user`, or none for a site screen), never from |
| 354 |
* `WP_NETWORK_ADMIN`. A plain `admin` screen therefore turned a |
| 355 |
* network-admin probe into a site request for everything after it: |
| 356 |
* `self_admin_url()` resolved every network menu slug against the site |
| 357 |
* admin, and a live refresh on the network shell painted a dock whose |
| 358 |
* tiles opened site pages. Read the context here, BEFORE any screen |
| 359 |
* exists, where the answer still comes from the request itself. |
| 360 |
* |
| 361 |
* @return string Screen id carrying the request's admin context. |
| 362 |
*/ |
| 363 |
function openstation_menu_refresh_probe_screen_id() { |
| 364 |
if ( is_network_admin() ) { |
| 365 |
return 'admin-network'; |
| 366 |
} |
| 367 |
if ( is_user_admin() ) { |
| 368 |
return 'admin-user'; |
| 369 |
} |
| 370 |
return 'admin'; |
| 371 |
} |
| 372 |
|
| 373 |
/** |
| 374 |
* Outputs the chromeless screen-meta bridge script. |
| 375 |
* |
| 376 |
* Detects Screen Options / Help panels in the iframed page and relays |
| 377 |
* their availability + open/closed state to the parent desktop shell |
| 378 |
* via postMessage. The parent shell uses this to render matching |
| 379 |
* buttons in the window title bar. |
| 380 |
*/ |
| 381 |
function openstation_chromeless_bridge_script() { |
| 382 |
if ( ! openstation_is_chromeless_request() ) { |
| 383 |
return; |
| 384 |
} |
| 385 |
|
| 386 |
/** |
| 387 |
* Fires after chromeless content in OpenStation. |
| 388 |
* |
| 389 |
* @param string $hook_suffix The current admin page hook suffix. |
| 390 |
*/ |
| 391 |
do_action( 'openstation_chromeless_after', isset( $GLOBALS['hook_suffix'] ) ? $GLOBALS['hook_suffix'] : '' ); |
| 392 |
|
| 393 |
// Menu payload — built from the LIVE $menu / $submenu globals |
| 394 |
// populated by real admin-context bootstrapping. We capture it here |
| 395 |
// rather than making the parent refetch via REST because many |
| 396 |
// plugins evaluate `is_admin()` at plugin-file-load time and only |
| 397 |
// register their `admin_menu` hook when it returns true; in a REST |
| 398 |
// context `WP_ADMIN` isn't defined at load, so those plugins never |
| 399 |
// hook in and their menu entries are missing from any endpoint we |
| 400 |
// could expose. Here we're INSIDE an admin request (plugins.php, |
| 401 |
// plugin-install.php, update.php, themes.php) where every plugin's |
| 402 |
// menu registered normally, so `$menu` carries the authoritative |
| 403 |
// post-activation state. |
| 404 |
// |
| 405 |
// Narrowed to the set of pages whose completion commonly mutates |
| 406 |
// the admin menu (activation / deactivation / install / theme |
| 407 |
// switch), plus the explicit `openstation_menu_refresh=1` signal |
| 408 |
// the shell sets when `wp.os.refreshMenu()` spawns a hidden |
| 409 |
// iframe to harvest a fresh payload from real admin context. |
| 410 |
// Navigating to edit.php or similar doesn't change the menu so we |
| 411 |
// don't bother sending a payload otherwise — the debounce + |
| 412 |
// idempotent replaceItems on the parent side would still make it |
| 413 |
// safe, just wasteful. |
| 414 |
$menu_payload_json = 'null'; |
| 415 |
$pagenow = isset( $GLOBALS['pagenow'] ) ? (string) $GLOBALS['pagenow'] : ''; |
| 416 |
$is_refresh_probe = ! empty( $_GET['openstation_menu_refresh'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only payload harvest, capability-gated by the host admin page. |
| 417 |
if ( |
| 418 |
$is_refresh_probe |
| 419 |
|| in_array( |
| 420 |
$pagenow, |
| 421 |
array( 'plugins.php', 'plugin-install.php', 'update.php', 'themes.php' ), |
| 422 |
true |
| 423 |
) |
| 424 |
) { |
| 425 |
$encoded = wp_json_encode( openstation_build_menu_payload() ); |
| 426 |
if ( false !== $encoded ) { |
| 427 |
$menu_payload_json = $encoded; |
| 428 |
} |
| 429 |
} |
| 430 |
|
| 431 |
// Content identity — which object this admin page shows ("comment 45 |
| 432 |
// of post 123"). Built here, in real admin context, because the URL |
| 433 |
// alone can't resolve relations like comment → parent post. Always |
| 434 |
// emitted (including `null`) so navigating an iframe from an |
| 435 |
// identified page to an unidentified one clears the stale identity |
| 436 |
// in the parent's relations engine. |
| 437 |
$content_identity_json = wp_json_encode( openstation_build_content_identity() ); |
| 438 |
if ( false === $content_identity_json ) { |
| 439 |
$content_identity_json = 'null'; |
| 440 |
} |
| 441 |
|
| 442 |
// On pages that don't carry a full payload, ship the lightweight |
| 443 |
// menu signature so the shell can detect an off-allowlist menu |
| 444 |
// change (e.g. a CPT registered via a settings tool) and refresh |
| 445 |
// only then. The full payload already embeds its own `menuSig`, so |
| 446 |
// there's no point recomputing it when one is being sent. GH#325. |
| 447 |
$menu_sig_json = 'null'; |
| 448 |
if ( 'null' === $menu_payload_json ) { |
| 449 |
$menu_sig = openstation_menu_signature(); |
| 450 |
if ( '' !== $menu_sig ) { |
| 451 |
$encoded_sig = wp_json_encode( $menu_sig ); |
| 452 |
if ( false !== $encoded_sig ) { |
| 453 |
$menu_sig_json = $encoded_sig; |
| 454 |
} |
| 455 |
} |
| 456 |
} |
| 457 |
|
| 458 |
// Declarative soft-reload rules for list screens that are NOT a |
| 459 |
// standard `edit.php?post_type=<type>` / `upload.php` / |
| 460 |
// `edit-comments.php` page (those are matched generically in the |
| 461 |
// bridge script). Rule shape: |
| 462 |
// - `topic` — the `os.<type>.changed` topic. |
| 463 |
// - `path` — wp-admin filename (`admin.php`). |
| 464 |
// - `query` — required query params (exact match). |
| 465 |
// - `queryAbsent` — params that must NOT be present. |
| 466 |
// |
| 467 |
// The default rule covers WooCommerce's HPOS orders list. |
| 468 |
// `queryAbsent: [ 'action' ]` is load-bearing: with `&action=edit` |
| 469 |
// the same path is the single-order EDITOR, which must keep the |
| 470 |
// single-edit exclusion (a soft reload would destroy unsaved |
| 471 |
// order state). Shipped unconditionally — when WooCommerce is |
| 472 |
// absent the URL never renders and the rule is inert. |
| 473 |
$soft_reload_rules = array( |
| 474 |
array( |
| 475 |
'topic' => 'os.shop_order.changed', |
| 476 |
'path' => 'admin.php', |
| 477 |
'query' => array( 'page' => 'wc-orders' ), |
| 478 |
'queryAbsent' => array( 'action' ), |
| 479 |
), |
| 480 |
); |
| 481 |
|
| 482 |
/** |
| 483 |
* Filters the declarative soft-reload rules injected into every |
| 484 |
* chromeless iframe. |
| 485 |
* |
| 486 |
* Lets a plugin whose list screen lives on a custom admin URL |
| 487 |
* participate in cross-window refresh: pair a rule here with |
| 488 |
* `openstation_content_changes_record()` calls (or your own |
| 489 |
* `os.<type>.changed` broadcasts) on the publish side. |
| 490 |
* |
| 491 |
* @param array $soft_reload_rules Rule arrays with keys `topic`, |
| 492 |
* `path`, `query`, `queryAbsent`. |
| 493 |
*/ |
| 494 |
$soft_reload_rules = (array) apply_filters( 'openstation_soft_reload_rules', $soft_reload_rules ); |
| 495 |
$soft_reload_json = wp_json_encode( array_values( $soft_reload_rules ) ); |
| 496 |
if ( ! $soft_reload_json ) { |
| 497 |
$soft_reload_json = '[]'; |
| 498 |
} |
| 499 |
|
| 500 |
// Per-request data for the bridge bundle. |
| 501 |
// |
| 502 |
// This used to be a `str_replace()` pass over a nowdoc holding the |
| 503 |
// whole bridge, printed inline — roughly 125 KB of unminified |
| 504 |
// JavaScript in the HTML of every window, comments included. The |
| 505 |
// document is the one asset no cache can help with, so that cost |
| 506 |
// was paid in full on every window open. The code now lives in |
| 507 |
// `src/chromeless-bridge.js` and builds to a bundle that is |
| 508 |
// fetched once and served from cache (browser, and the shared |
| 509 |
// service-worker cache) for every later window; only these four |
| 510 |
// values still have to vary per request. |
| 511 |
// |
| 512 |
// `wp_json_encode` already guarantees safe JSON output, so the |
| 513 |
// values are interpolated as-is — the same guarantee the |
| 514 |
// `str_replace` pass relied on. Keys are underscore-prefixed to |
| 515 |
// mark them as a private contract with the bundle rather than a |
| 516 |
// public API; `src/chromeless-bridge.js` reads exactly these four. |
| 517 |
$data = sprintf( |
| 518 |
'window.__osChromelessData = { _menuPayload: %s, _menuSig: %s, _identity: %s, _softReload: %s };', |
| 519 |
$menu_payload_json, |
| 520 |
$menu_sig_json, |
| 521 |
$content_identity_json, |
| 522 |
$soft_reload_json |
| 523 |
); |
| 524 |
|
| 525 |
// `in_footer` + a `before` inline block reproduces exactly what the |
| 526 |
// old inline print did: the data is defined, then the bridge runs, |
| 527 |
// both at the same point in the document. No `defer` / `async` — |
| 528 |
// the bridge expects to execute synchronously here, and deferring |
| 529 |
// it would move it after the page's own footer scripts. |
| 530 |
// Defensive re-registration. `WP_Dependencies::enqueue()` silently |
| 531 |
// no-ops on a handle that isn't registered, and plugins that |
| 532 |
// rebuild `WP_Scripts` wholesale are a real, documented shape — |
| 533 |
// see `includes/render/asset-guard.php` for the same class of |
| 534 |
// conflict. The bridge is the one script a window genuinely cannot |
| 535 |
// do without: losing it costs the window its title, its links, its |
| 536 |
// activity ring and its refresh signalling. `wp_register_script()` |
| 537 |
// no-ops when the handle is already there, so this costs nothing |
| 538 |
// on the normal path. |
| 539 |
if ( ! wp_script_is( 'os-chromeless-bridge', 'registered' ) ) { |
| 540 |
openstation_register_assets(); |
| 541 |
} |
| 542 |
|
| 543 |
wp_enqueue_script( 'os-chromeless-bridge' ); |
| 544 |
wp_add_inline_script( 'os-chromeless-bridge', $data, 'before' ); |
| 545 |
} |
| 546 |
add_action( 'admin_footer', 'openstation_chromeless_bridge_script' ); |
| 547 |
|