PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.13
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.13
1.1.13 1.1.12 1.1.11 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 All 37 releases
desktop-mode / includes / render / chromeless-bridge.php

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

650 lines 27.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 * 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 * It also says where the frame landed, for documents the shell cannot
214 * read: one sent with `Document-Isolation-Policy`, as WordPress sends
215 * the block editor in Chromium, is cross-origin to the parent, and a
216 * Revisions window that restores into the editor still has to hand off.
217 *
218 * The parent acts on it only for a window with a submit waiting, or
219 * one whose frame it cannot read.
220 *
221 * A document can also change its address without navigating, and the
222 * block editor does on its first save: `post-new.php` becomes
223 * `post.php?post=N&action=edit` through `history.replaceState()`. For
224 * a frame the parent cannot read, nothing else tells it, so the window
225 * would go on calling the draft "a new post", and "Copy link" would
226 * hand out an empty editor. `os-iframe-location` reports every such
227 * change, and only updates where the parent thinks the frame is: it is
228 * not a navigation, so it settles no submit and resets no title. The
229 * wrap is installed here, ahead of every other script, so Core's own
230 * canonical-URL `replaceState()` in this same head is reported too.
231 */
232 function openstation_chromeless_navigation_ping_script() {
233 if ( ! openstation_is_chromeless_request() ) {
234 return;
235 }
236
237 wp_print_inline_script_tag(
238 'try{if(window.parent&&window.parent!==window){'
239 . "window.parent.postMessage({type:'os-iframe-navigated',url:window.location.href},window.location.origin);"
240 . 'var osParent=window.parent,osOrigin=window.location.origin,'
241 . "osMoved=function(){try{osParent.postMessage({type:'os-iframe-location',url:window.location.href},osOrigin);}catch(e){}};"
242 . "['pushState','replaceState'].forEach(function(name){"
243 . 'var original=window.history[name];'
244 . "if(typeof original!=='function'){return;}"
245 . 'window.history[name]=function(){var result=original.apply(this,arguments);'
246 . 'osMoved();return result;};'
247 . '});'
248 . "window.addEventListener('popstate',osMoved);"
249 . '}}catch(e){}'
250 );
251 }
252 add_action( 'admin_head', 'openstation_chromeless_navigation_ping_script', 1 );
253
254 /**
255 * Short-circuit `admin.php?openstation_menu_refresh=1` requests with
256 * a tiny inline-script response that postMessages the current menu
257 * payload to the parent shell.
258 *
259 * The full chromeless bridge is hooked on `admin_footer`, which Core
260 * only fires from `admin-header.php` / `admin-footer.php`. Plain
261 * `admin.php` without `?page=` (or one of the other dispatch paths
262 * in admin.php) never includes the footer — the file just runs the
263 * `load-{$pagenow}` hook in the `else` branch and exits. The full
264 * bridge therefore never emits its payload, and the parent's
265 * `wp.os.refreshMenu()` waits out its 8-second timeout for a
266 * message that's never coming. That's the source of "deactivating a
267 * plugin leaves its dock icons behind" — the hidden probe iframe
268 * the shell spawns to harvest the post-mutation menu lands on a
269 * page that doesn't fire admin_footer.
270 *
271 * Hooking here on `admin_init @ 99` runs AFTER `wp-admin/menu.php`
272 * has loaded (which fires `admin_menu` and populates `$menu`) but
273 * BEFORE admin.php's per-page dispatch. We can emit the payload
274 * straight away and short-circuit the rest of admin.php so the probe
275 * resolves in milliseconds instead of timing out.
276 *
277 * No admin-header / admin-footer means no `#adminmenu` DOM, so the
278 * full bridge's CSS-icon harvest doesn't run here. That's an
279 * acceptable trade-off: items whose icons live in `$menu[$i][6]`
280 * (the vast majority) still ship correctly; items that rely on a
281 * CSS `::before` on `#adminmenu .menu-icon-<slug>` fall back to the
282 * default gear icon on a live refresh until the next full page load
283 * — strictly better than today's "dock doesn't update at all."
284 */
285 function openstation_emit_menu_refresh_probe() {
286 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only payload harvest; capability-gated by chromeless gate below.
287 if ( empty( $_GET['openstation_menu_refresh'] ) ) {
288 return;
289 }
290 if ( ! openstation_is_chromeless_request() ) {
291 return;
292 }
293 // Only short-circuit the bare `admin.php` probe — for any real
294 // admin page (plugins.php, edit.php, etc.) we still want the full
295 // admin-footer-hosted bridge to fire so the icon harvest runs.
296 $pagenow = isset( $GLOBALS['pagenow'] ) ? (string) $GLOBALS['pagenow'] : '';
297 if ( 'admin.php' !== $pagenow ) {
298 return;
299 }
300
301 $payload = openstation_menu_refresh_probe_payload();
302 $encoded = wp_json_encode( $payload );
303 if ( false === $encoded ) {
304 return;
305 }
306
307 nocache_headers();
308 header( 'Content-Type: text/html; charset=utf-8' );
309
310 // Mirror the full bridge's message shape so the same shell-side
311 // listener consumes both.
312 echo '<!doctype html><html><head><meta charset="utf-8"><title></title></head><body>';
313 echo '<script>';
314 echo '(function(){try{if(window.parent&&window.parent!==window){window.parent.postMessage({type:"os-plugins-changed",payload:';
315 echo $encoded; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode produces JSON-safe output.
316 echo '},window.location.origin);}}catch(e){}})();';
317 echo '</script>';
318 echo '</body></html>';
319 exit;
320 }
321 add_action( 'admin_init', 'openstation_emit_menu_refresh_probe', 99 );
322
323 /**
324 * Build the menu payload the refresh probe emits, with the script
325 * data the harvest depends on attached first.
326 *
327 * `openstation_build_menu_payload()` harvests every lazy native
328 * window's handle-attached data — `wp_localize_script` blobs and
329 * `wp_add_inline_script` snippets — via
330 * `openstation_resolve_script_payload()`. Modules attach that data on
331 * `admin_enqueue_scripts` at priority ≤ 5 (the contract
332 * `Tests_OpenStation_LazyWindowConfigPriority` pins), which holds on
333 * every payload producer except this one: the probe short-circuits
334 * `admin.php` on `admin_init`, long before Core would fire the
335 * enqueue hook, so nothing was ever attached and the harvested
336 * entries shipped with empty `scriptBefore` / `scriptL10n` arrays.
337 *
338 * The shell refreshes its native-window index from every payload it
339 * receives, so one probe response silently downgraded windows the
340 * boot payload had delivered complete — the first lazy open of WP
341 * Explorer after a menu refresh found the WooCommerce companion with
342 * no `openStationWooConfig`, and the store's order bands and preview
343 * panels went dark with nothing in the console to say why.
344 *
345 * Replaying the hook here makes the probe's request faithful to the
346 * real admin page it stands in for. The output buffer guards the
347 * short-circuit response: an enqueue callback that echoes must not
348 * beat our `header()` calls. Enqueued handles are never printed —
349 * the probe exits before any print pipeline runs.
350 *
351 * @return array Menu payload, same shape as `openstation_build_menu_payload()`.
352 */
353 function openstation_menu_refresh_probe_payload() {
354 if ( ! did_action( 'admin_enqueue_scripts' ) ) {
355 // `admin.php` calls `set_current_screen()` AFTER `admin_init`,
356 // so at probe time there is no screen yet — and Core's own
357 // enqueue callbacks (the block-editor script loader among
358 // them) read `get_current_screen()->id` unguarded. Build the
359 // screen the real flow would have had before the hook fires,
360 // in the request's own admin context — see
361 // `openstation_menu_refresh_probe_screen_id()`.
362 if ( function_exists( 'set_current_screen' ) && function_exists( 'get_current_screen' ) && ! get_current_screen() ) {
363 set_current_screen( openstation_menu_refresh_probe_screen_id() );
364 }
365 ob_start();
366 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- deliberate replay of Core's own hook so handle-attached script data exists before the harvest below.
367 do_action( 'admin_enqueue_scripts', 'admin.php' );
368 ob_end_clean();
369 }
370
371 return openstation_build_menu_payload();
372 }
373
374 /**
375 * The screen id the refresh probe builds its placeholder `WP_Screen`
376 * from: `admin-network` in the network admin, `admin-user` in the user
377 * admin, `admin` on a site.
378 *
379 * Once a screen exists, `is_network_admin()` answers from it — and a
380 * screen built from a bare id gets its admin context from the id's
381 * SUFFIX (`-network`, `-user`, or none for a site screen), never from
382 * `WP_NETWORK_ADMIN`. A plain `admin` screen therefore turned a
383 * network-admin probe into a site request for everything after it:
384 * `self_admin_url()` resolved every network menu slug against the site
385 * admin, and a live refresh on the network shell painted a dock whose
386 * tiles opened site pages. Read the context here, BEFORE any screen
387 * exists, where the answer still comes from the request itself.
388 *
389 * @return string Screen id carrying the request's admin context.
390 */
391 function openstation_menu_refresh_probe_screen_id() {
392 if ( is_network_admin() ) {
393 return 'admin-network';
394 }
395 if ( is_user_admin() ) {
396 return 'admin-user';
397 }
398 return 'admin';
399 }
400
401 /**
402 * Outputs the chromeless screen-meta bridge script.
403 *
404 * Detects Screen Options / Help panels in the iframed page and relays
405 * their availability + open/closed state to the parent desktop shell
406 * via postMessage. The parent shell uses this to render matching
407 * buttons in the window title bar.
408 */
409 function openstation_chromeless_bridge_script() {
410 if ( ! openstation_is_chromeless_request() ) {
411 return;
412 }
413
414 /**
415 * Fires after chromeless content in OpenStation.
416 *
417 * @param string $hook_suffix The current admin page hook suffix.
418 */
419 do_action( 'openstation_chromeless_after', isset( $GLOBALS['hook_suffix'] ) ? $GLOBALS['hook_suffix'] : '' );
420
421 // Menu payload — built from the LIVE $menu / $submenu globals
422 // populated by real admin-context bootstrapping. We capture it here
423 // rather than making the parent refetch via REST because many
424 // plugins evaluate `is_admin()` at plugin-file-load time and only
425 // register their `admin_menu` hook when it returns true; in a REST
426 // context `WP_ADMIN` isn't defined at load, so those plugins never
427 // hook in and their menu entries are missing from any endpoint we
428 // could expose. Here we're INSIDE an admin request (plugins.php,
429 // plugin-install.php, update.php, themes.php) where every plugin's
430 // menu registered normally, so `$menu` carries the authoritative
431 // post-activation state.
432 //
433 // Narrowed to the set of pages whose completion commonly mutates
434 // the admin menu (activation / deactivation / install / theme
435 // switch), plus the explicit `openstation_menu_refresh=1` signal
436 // the shell sets when `wp.os.refreshMenu()` spawns a hidden
437 // iframe to harvest a fresh payload from real admin context.
438 // Navigating to edit.php or similar doesn't change the menu so we
439 // don't bother sending a payload otherwise — the debounce +
440 // idempotent replaceItems on the parent side would still make it
441 // safe, just wasteful.
442 $menu_payload_json = 'null';
443 $pagenow = isset( $GLOBALS['pagenow'] ) ? (string) $GLOBALS['pagenow'] : '';
444 $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.
445 if (
446 $is_refresh_probe
447 || in_array(
448 $pagenow,
449 array( 'plugins.php', 'plugin-install.php', 'update.php', 'themes.php' ),
450 true
451 )
452 ) {
453 $encoded = wp_json_encode( openstation_build_menu_payload() );
454 if ( false !== $encoded ) {
455 $menu_payload_json = $encoded;
456 }
457 }
458
459 // Content identity — which object this admin page shows ("comment 45
460 // of post 123"). Built here, in real admin context, because the URL
461 // alone can't resolve relations like comment → parent post. Always
462 // emitted (including `null`) so navigating an iframe from an
463 // identified page to an unidentified one clears the stale identity
464 // in the parent's relations engine.
465 $content_identity_json = wp_json_encode( openstation_build_content_identity() );
466 if ( false === $content_identity_json ) {
467 $content_identity_json = 'null';
468 }
469
470 // On pages that don't carry a full payload, ship the lightweight
471 // menu signature so the shell can detect an off-allowlist menu
472 // change (e.g. a CPT registered via a settings tool) and refresh
473 // only then. The full payload already embeds its own `menuSig`, so
474 // there's no point recomputing it when one is being sent. GH#325.
475 $menu_sig_json = 'null';
476 if ( 'null' === $menu_payload_json ) {
477 $menu_sig = openstation_menu_signature();
478 if ( '' !== $menu_sig ) {
479 $encoded_sig = wp_json_encode( $menu_sig );
480 if ( false !== $encoded_sig ) {
481 $menu_sig_json = $encoded_sig;
482 }
483 }
484 }
485
486 // Declarative soft-reload rules for list screens that are NOT a
487 // standard `edit.php?post_type=<type>` / `upload.php` /
488 // `edit-comments.php` page (those are matched generically in the
489 // bridge script). Rule shape:
490 // - `topic` — the `os.<type>.changed` topic.
491 // - `path` — wp-admin filename (`admin.php`).
492 // - `query` — required query params (exact match).
493 // - `queryAbsent` — params that must NOT be present.
494 //
495 // The default rule covers WooCommerce's HPOS orders list.
496 // `queryAbsent: [ 'action' ]` is load-bearing: with `&action=edit`
497 // the same path is the single-order EDITOR, which must keep the
498 // single-edit exclusion (a soft reload would destroy unsaved
499 // order state). Shipped unconditionally — when WooCommerce is
500 // absent the URL never renders and the rule is inert.
501 $soft_reload_rules = array(
502 array(
503 'topic' => 'os.shop_order.changed',
504 'path' => 'admin.php',
505 'query' => array( 'page' => 'wc-orders' ),
506 'queryAbsent' => array( 'action' ),
507 ),
508 );
509
510 /**
511 * Filters the declarative soft-reload rules injected into every
512 * chromeless iframe.
513 *
514 * Lets a plugin whose list screen lives on a custom admin URL
515 * participate in cross-window refresh: pair a rule here with
516 * `openstation_content_changes_record()` calls (or your own
517 * `os.<type>.changed` broadcasts) on the publish side.
518 *
519 * @param array $soft_reload_rules Rule arrays with keys `topic`,
520 * `path`, `query`, `queryAbsent`.
521 */
522 $soft_reload_rules = (array) apply_filters( 'openstation_soft_reload_rules', $soft_reload_rules );
523 $soft_reload_json = wp_json_encode( array_values( $soft_reload_rules ) );
524 if ( ! $soft_reload_json ) {
525 $soft_reload_json = '[]';
526 }
527
528 // Per-request data for the bridge bundle.
529 //
530 // This used to be a `str_replace()` pass over a nowdoc holding the
531 // whole bridge, printed inline — roughly 125 KB of unminified
532 // JavaScript in the HTML of every window, comments included. The
533 // document is the one asset no cache can help with, so that cost
534 // was paid in full on every window open. The code now lives in
535 // `src/chromeless-bridge.js` and builds to a bundle that is
536 // fetched once and served from cache (browser, and the shared
537 // service-worker cache) for every later window; only these four
538 // values still have to vary per request.
539 //
540 // `wp_json_encode` already guarantees safe JSON output, so the
541 // values are interpolated as-is — the same guarantee the
542 // `str_replace` pass relied on. Keys are underscore-prefixed to
543 // mark them as a private contract with the bundle rather than a
544 // public API; `src/chromeless-bridge.js` reads exactly these four.
545 $data = sprintf(
546 'window.__osChromelessData = { _menuPayload: %s, _menuSig: %s, _identity: %s, _softReload: %s };',
547 $menu_payload_json,
548 $menu_sig_json,
549 $content_identity_json,
550 $soft_reload_json
551 );
552
553 // `in_footer` + a `before` inline block reproduces exactly what the
554 // old inline print did: the data is defined, then the bridge runs,
555 // both at the same point in the document. No `defer` / `async` —
556 // the bridge expects to execute synchronously here, and deferring
557 // it would move it after the page's own footer scripts.
558 // Defensive re-registration. `WP_Dependencies::enqueue()` silently
559 // no-ops on a handle that isn't registered, and plugins that
560 // rebuild `WP_Scripts` wholesale are a real, documented shape —
561 // see `includes/render/asset-guard.php` for the same class of
562 // conflict. The bridge is the one script a window genuinely cannot
563 // do without: losing it costs the window its title, its links, its
564 // activity ring and its refresh signalling. `wp_register_script()`
565 // no-ops when the handle is already there, so this costs nothing
566 // on the normal path.
567 if ( ! wp_script_is( 'os-chromeless-bridge', 'registered' ) ) {
568 openstation_register_assets();
569 }
570
571 wp_enqueue_script( 'os-chromeless-bridge' );
572 wp_add_inline_script( 'os-chromeless-bridge', $data, 'before' );
573 }
574 add_action( 'admin_footer', 'openstation_chromeless_bridge_script' );
575
576 /**
577 * Transient holding the screen a plugin redirected a plugins window to.
578 *
579 * @param int $user_id User ID.
580 * @return string Transient name.
581 */
582 function openstation_plugins_handoff_key( $user_id ) {
583 return 'openstation_plugins_handoff_' . (int) $user_id;
584 }
585
586 /**
587 * Keeps a plugins window on the plugins screen when a plugin redirects
588 * away from it (Elementor sends a fresh install to its onboarding), and
589 * leaves the destination for that screen to open in a window of its
590 * own, the way a link to it would. One hop only, so a plugin that
591 * redirects on every load cannot loop.
592 *
593 * @param string $location Redirect URL.
594 * @return string The redirect URL, or the plugins screen to land on.
595 */
596 function openstation_chromeless_hand_off_plugins_redirect( $location ) {
597 if ( empty( $location ) || 'plugins.php' !== ( $GLOBALS['pagenow'] ?? '' ) ) {
598 return $location;
599 }
600 if ( ! openstation_is_chromeless_request() || ! openstation_is_admin_redirect_target( $location ) ) {
601 return $location;
602 }
603 $destination = WP_Http::make_absolute_url( $location, self_admin_url() );
604 if ( 'plugins.php' === basename( (string) wp_parse_url( $destination, PHP_URL_PATH ) ) ) {
605 return $location;
606 }
607 $key = openstation_plugins_handoff_key( get_current_user_id() );
608 if ( false !== get_transient( $key ) ) {
609 delete_transient( $key );
610 return $location;
611 }
612 set_transient( $key, $destination, MINUTE_IN_SECONDS );
613
614 // A redirect from inside an action request (a plugin's own
615 // `activated_plugin` hook) must not run that action again.
616 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- only asks whether this is an action request.
617 if ( empty( $_REQUEST['action'] ) && isset( $_SERVER['REQUEST_URI'] ) ) {
618 return esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
619 }
620 return self_admin_url( 'plugins.php' );
621 }
622 add_filter( 'wp_redirect', 'openstation_chromeless_hand_off_plugins_redirect', 998 );
623
624 /**
625 * Opens the screen {@see openstation_chromeless_hand_off_plugins_redirect()}
626 * kept back, posted as the bridge posts a `target="_blank"` admin link.
627 */
628 function openstation_chromeless_open_handed_off_redirect() {
629 if ( 'plugins.php' !== ( $GLOBALS['pagenow'] ?? '' ) || ! openstation_is_chromeless_request() ) {
630 return;
631 }
632 $key = openstation_plugins_handoff_key( get_current_user_id() );
633 $destination = get_transient( $key );
634 if ( ! is_string( $destination ) || '' === $destination ) {
635 return;
636 }
637 delete_transient( $key );
638
639 $message = wp_json_encode(
640 array(
641 'type' => 'os-iframe-admin-link',
642 'url' => $destination,
643 'label' => '',
644 'newContext' => true,
645 )
646 );
647 wp_print_inline_script_tag( 'try{window.parent.postMessage(' . $message . ',window.location.origin);}catch(e){}' );
648 }
649 add_action( 'admin_footer', 'openstation_chromeless_open_handed_off_redirect' );
650