PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.8
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 0.8.6 All 33 releases
desktop-mode / includes / render / chromeless-bridge.php

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

547 lines 23.3 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 * 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