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

518 lines 22.1 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 if ( function_exists( 'set_current_screen' ) && function_exists( 'get_current_screen' ) && ! get_current_screen() ) {
333 set_current_screen( 'admin' );
334 }
335 ob_start();
336 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- deliberate replay of Core's own hook so handle-attached script data exists before the harvest below.
337 do_action( 'admin_enqueue_scripts', 'admin.php' );
338 ob_end_clean();
339 }
340
341 return openstation_build_menu_payload();
342 }
343
344 /**
345 * Outputs the chromeless screen-meta bridge script.
346 *
347 * Detects Screen Options / Help panels in the iframed page and relays
348 * their availability + open/closed state to the parent desktop shell
349 * via postMessage. The parent shell uses this to render matching
350 * buttons in the window title bar.
351 */
352 function openstation_chromeless_bridge_script() {
353 if ( ! openstation_is_chromeless_request() ) {
354 return;
355 }
356
357 /**
358 * Fires after chromeless content in OpenStation.
359 *
360 * @param string $hook_suffix The current admin page hook suffix.
361 */
362 do_action( 'openstation_chromeless_after', isset( $GLOBALS['hook_suffix'] ) ? $GLOBALS['hook_suffix'] : '' );
363
364 // Menu payload — built from the LIVE $menu / $submenu globals
365 // populated by real admin-context bootstrapping. We capture it here
366 // rather than making the parent refetch via REST because many
367 // plugins evaluate `is_admin()` at plugin-file-load time and only
368 // register their `admin_menu` hook when it returns true; in a REST
369 // context `WP_ADMIN` isn't defined at load, so those plugins never
370 // hook in and their menu entries are missing from any endpoint we
371 // could expose. Here we're INSIDE an admin request (plugins.php,
372 // plugin-install.php, update.php, themes.php) where every plugin's
373 // menu registered normally, so `$menu` carries the authoritative
374 // post-activation state.
375 //
376 // Narrowed to the set of pages whose completion commonly mutates
377 // the admin menu (activation / deactivation / install / theme
378 // switch), plus the explicit `openstation_menu_refresh=1` signal
379 // the shell sets when `wp.os.refreshMenu()` spawns a hidden
380 // iframe to harvest a fresh payload from real admin context.
381 // Navigating to edit.php or similar doesn't change the menu so we
382 // don't bother sending a payload otherwise — the debounce +
383 // idempotent replaceItems on the parent side would still make it
384 // safe, just wasteful.
385 $menu_payload_json = 'null';
386 $pagenow = isset( $GLOBALS['pagenow'] ) ? (string) $GLOBALS['pagenow'] : '';
387 $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.
388 if (
389 $is_refresh_probe
390 || in_array(
391 $pagenow,
392 array( 'plugins.php', 'plugin-install.php', 'update.php', 'themes.php' ),
393 true
394 )
395 ) {
396 $encoded = wp_json_encode( openstation_build_menu_payload() );
397 if ( false !== $encoded ) {
398 $menu_payload_json = $encoded;
399 }
400 }
401
402 // Content identity — which object this admin page shows ("comment 45
403 // of post 123"). Built here, in real admin context, because the URL
404 // alone can't resolve relations like comment → parent post. Always
405 // emitted (including `null`) so navigating an iframe from an
406 // identified page to an unidentified one clears the stale identity
407 // in the parent's relations engine.
408 $content_identity_json = wp_json_encode( openstation_build_content_identity() );
409 if ( false === $content_identity_json ) {
410 $content_identity_json = 'null';
411 }
412
413 // On pages that don't carry a full payload, ship the lightweight
414 // menu signature so the shell can detect an off-allowlist menu
415 // change (e.g. a CPT registered via a settings tool) and refresh
416 // only then. The full payload already embeds its own `menuSig`, so
417 // there's no point recomputing it when one is being sent. GH#325.
418 $menu_sig_json = 'null';
419 if ( 'null' === $menu_payload_json ) {
420 $menu_sig = openstation_menu_signature();
421 if ( '' !== $menu_sig ) {
422 $encoded_sig = wp_json_encode( $menu_sig );
423 if ( false !== $encoded_sig ) {
424 $menu_sig_json = $encoded_sig;
425 }
426 }
427 }
428
429 // Declarative soft-reload rules for list screens that are NOT a
430 // standard `edit.php?post_type=<type>` / `upload.php` /
431 // `edit-comments.php` page (those are matched generically in the
432 // bridge script). Rule shape:
433 // - `topic` — the `os.<type>.changed` topic.
434 // - `path` — wp-admin filename (`admin.php`).
435 // - `query` — required query params (exact match).
436 // - `queryAbsent` — params that must NOT be present.
437 //
438 // The default rule covers WooCommerce's HPOS orders list.
439 // `queryAbsent: [ 'action' ]` is load-bearing: with `&action=edit`
440 // the same path is the single-order EDITOR, which must keep the
441 // single-edit exclusion (a soft reload would destroy unsaved
442 // order state). Shipped unconditionally — when WooCommerce is
443 // absent the URL never renders and the rule is inert.
444 $soft_reload_rules = array(
445 array(
446 'topic' => 'os.shop_order.changed',
447 'path' => 'admin.php',
448 'query' => array( 'page' => 'wc-orders' ),
449 'queryAbsent' => array( 'action' ),
450 ),
451 );
452
453 /**
454 * Filters the declarative soft-reload rules injected into every
455 * chromeless iframe.
456 *
457 * Lets a plugin whose list screen lives on a custom admin URL
458 * participate in cross-window refresh: pair a rule here with
459 * `openstation_content_changes_record()` calls (or your own
460 * `os.<type>.changed` broadcasts) on the publish side.
461 *
462 * @param array $soft_reload_rules Rule arrays with keys `topic`,
463 * `path`, `query`, `queryAbsent`.
464 */
465 $soft_reload_rules = (array) apply_filters( 'openstation_soft_reload_rules', $soft_reload_rules );
466 $soft_reload_json = wp_json_encode( array_values( $soft_reload_rules ) );
467 if ( ! $soft_reload_json ) {
468 $soft_reload_json = '[]';
469 }
470
471 // Per-request data for the bridge bundle.
472 //
473 // This used to be a `str_replace()` pass over a nowdoc holding the
474 // whole bridge, printed inline — roughly 125 KB of unminified
475 // JavaScript in the HTML of every window, comments included. The
476 // document is the one asset no cache can help with, so that cost
477 // was paid in full on every window open. The code now lives in
478 // `src/chromeless-bridge.js` and builds to a bundle that is
479 // fetched once and served from cache (browser, and the shared
480 // service-worker cache) for every later window; only these four
481 // values still have to vary per request.
482 //
483 // `wp_json_encode` already guarantees safe JSON output, so the
484 // values are interpolated as-is — the same guarantee the
485 // `str_replace` pass relied on. Keys are underscore-prefixed to
486 // mark them as a private contract with the bundle rather than a
487 // public API; `src/chromeless-bridge.js` reads exactly these four.
488 $data = sprintf(
489 'window.__osChromelessData = { _menuPayload: %s, _menuSig: %s, _identity: %s, _softReload: %s };',
490 $menu_payload_json,
491 $menu_sig_json,
492 $content_identity_json,
493 $soft_reload_json
494 );
495
496 // `in_footer` + a `before` inline block reproduces exactly what the
497 // old inline print did: the data is defined, then the bridge runs,
498 // both at the same point in the document. No `defer` / `async` —
499 // the bridge expects to execute synchronously here, and deferring
500 // it would move it after the page's own footer scripts.
501 // Defensive re-registration. `WP_Dependencies::enqueue()` silently
502 // no-ops on a handle that isn't registered, and plugins that
503 // rebuild `WP_Scripts` wholesale are a real, documented shape —
504 // see `includes/render/asset-guard.php` for the same class of
505 // conflict. The bridge is the one script a window genuinely cannot
506 // do without: losing it costs the window its title, its links, its
507 // activity ring and its refresh signalling. `wp_register_script()`
508 // no-ops when the handle is already there, so this costs nothing
509 // on the normal path.
510 if ( ! wp_script_is( 'os-chromeless-bridge', 'registered' ) ) {
511 openstation_register_assets();
512 }
513
514 wp_enqueue_script( 'os-chromeless-bridge' );
515 wp_add_inline_script( 'os-chromeless-bridge', $data, 'before' );
516 }
517 add_action( 'admin_footer', 'openstation_chromeless_bridge_script' );
518