PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.9
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.9
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 / compat / divi.php

divi.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.9, at includes/compat/divi.php

501 lines 19.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Divi compatibility — script dependency repair.
4 *
5 * Divi (both the theme and the standalone Divi Builder plugin)
6 * registers its block-editor bundle `et-builder-gutenberg` with
7 * only `[ 'jquery', 'wp-hooks' ]` as dependencies. The bundle
8 * calls `wp.data.select( 'core/editor' ).isCleanNewPost` at
9 * module-load time (top-level statement, not inside a function),
10 * so it needs the `core/editor` data store to be registered before
11 * it executes. Without `wp-editor` (which pulls in `wp-data` and
12 * registers `core/editor`) in the dep array, WordPress doesn't
13 * guarantee that ordering — and when the bundle wins the race the
14 * `select( ... )` call returns `undefined` and the bundle throws:
15 *
16 * Uncaught TypeError: Cannot read properties of undefined
17 * (reading 'isCleanNewPost')
18 *
19 * The rest of Divi's React integration never mounts: no `Use Divi
20 * Builder` block on new posts, no `PluginSidebar`, no toggle. To
21 * the user it looks like Divi simply doesn't work inside a desktop
22 * window.
23 *
24 * We inject the missing deps onto the existing registration so the
25 * script loader prints `wp-editor`'s graph first and the bundle
26 * runs against a populated `wp.data`. The shim is idempotent: if
27 * Divi later ships the fix upstream (or renames the handle), this
28 * becomes a no-op.
29 *
30 * Reported to Elegant Themes. Remove this file when Divi ships
31 * the fix upstream.
32 *
33 * @package OpenStation\Compat
34 */
35
36 defined( 'ABSPATH' ) || exit;
37
38 /**
39 * Inject `wp-data` and `wp-editor` as dependencies on Divi's
40 * `et-builder-gutenberg` script registration, and (inside a
41 * chromeless iframe) override Divi's `window.et_gb` assignment so
42 * the bundle's webpack externals resolve to the iframe's own
43 * `wp.data`.
44 *
45 * Two problems on the same script registration:
46 *
47 * 1. **Missing deps.** Divi declares only `[ jquery, wp-hooks ]`
48 * but the bundle reads from `wp.data` at module-load time. We
49 * add `wp-data` + `wp-editor` so the loader prints them first.
50 *
51 * 2. **`window.et_gb` resolves to the wrong frame.** Divi's
52 * bundle is webpack-built with `@wordpress/data` externalised
53 * to `window.et_gb.wp.data` — not `window.wp.data`. The inline
54 * script Divi adds (`before` the bundle) sets `window.et_gb`
55 * via this expression:
56 *
57 * window.et_gb = (window.top && window.top.Cypress && …)
58 * || window.top // ← falls through to here
59 * || window;
60 *
61 * In classic admin `window.top === window`, so `et_gb =
62 * window` and `et_gb.wp.data` is the page's own `wp.data`.
63 * Inside our chromeless iframe `window.top` is the desktop
64 * shell — a different document with no `wp.data` — so
65 * `et_gb.wp.data` is undefined and the bundle throws on first
66 * access (`Cannot read properties of undefined (reading
67 * 'isCleanNewPost')`). The rest of Divi's React integration
68 * never mounts: no `Use Divi Builder` block on new posts, no
69 * `PluginSidebar`, no toggle.
70 *
71 * Multiple `wp_add_inline_script( …, 'before' )` calls
72 * concatenate in registration order, so appending our own
73 * `window.et_gb = window;` after Divi's lets our assignment
74 * win. Scoped to chromeless requests because Divi's original
75 * intent (use `window.top` when the parent is a Cypress
76 * harness) is sensible in other iframe contexts.
77 *
78 * Hooked at `enqueue_block_editor_assets` priority 999 so it runs
79 * after Divi's own enqueue (priority 4) but before the script
80 * loader prints `<script>` tags.
81 *
82 * Reported to Elegant Themes. Remove this file when Divi ships
83 * the fix upstream.
84 *
85 * @return void
86 */
87 function openstation_compat_divi_fix_gutenberg_deps() {
88 global $wp_scripts;
89
90 if ( ! ( $wp_scripts instanceof WP_Scripts ) ) {
91 return;
92 }
93
94 if ( ! isset( $wp_scripts->registered['et-builder-gutenberg'] ) ) {
95 return;
96 }
97
98 $registration = $wp_scripts->registered['et-builder-gutenberg'];
99 $existing = (array) $registration->deps;
100
101 foreach ( array( 'wp-data', 'wp-editor' ) as $dep ) {
102 if ( ! in_array( $dep, $existing, true ) ) {
103 $registration->deps[] = $dep;
104 }
105 }
106
107 if ( openstation_is_chromeless_request() ) {
108 wp_add_inline_script(
109 'et-builder-gutenberg',
110 'window.et_gb = window;',
111 'before'
112 );
113 }
114 }
115 add_action( 'enqueue_block_editor_assets', 'openstation_compat_divi_fix_gutenberg_deps', 999 );
116
117 /**
118 * Signal Divi's Visual Builder frame-helpers that the iframe context
119 * is "top-level-equivalent" so its `top_window` export resolves to
120 * the iframe's own `window` instead of the desktop shell.
121 *
122 * The VB front-end bundle includes a helper module
123 * (`frontend-builder/build/frame-helpers.js`) whose `top_window`
124 * resolver does roughly this at load time:
125 *
126 * try { u = !!window.top.document && window.top; }
127 * catch ( _ ) { u = false; }
128 * if ( u && u.__Cypress__ ) {
129 * top_window = ( window.parent === u ) ? window : window.parent;
130 * is_iframe = ( window.parent !== u );
131 * } else if ( u ) {
132 * top_window = u; // ← falls through to here
133 * is_iframe = ( u !== window.self );
134 * }
135 *
136 * Inside a chromeless iframe `window.top` is the desktop shell, so
137 * the `else if ( u )` branch fires: `top_window = window.top` (the
138 * shell), `is_iframe = true`. The rest of Divi's VB then routes
139 * REST nonces, builder state, and DOM ops through a window that
140 * has none of those things — VB sits permanently on its
141 * "et-fb-page-preloading" loader because the state it's waiting
142 * for will never arrive.
143 *
144 * Setting `__Cypress__` on `window.top` (our shell) makes Divi take
145 * the Cypress branch instead. Since we're a single-level iframe
146 * (`window.parent === window.top`), that branch resolves
147 * `top_window = window` (the iframe itself), `is_iframe = false`
148 * — exactly the classic-admin behavior. The flag costs nothing
149 * outside Divi (no other code in the WP stack reads `__Cypress__`)
150 * and is idempotent (we OR with the existing value).
151 *
152 * Scope: only when the current user has OpenStation enabled AND
153 * the rendered document is loaded inside an iframe (`window.top !==
154 * window`). The inline script is a few-byte no-op everywhere else.
155 * Front-end only — admin pages use `et_gb` (see above) and route
156 * through a different compat path.
157 *
158 * Reported to Elegant Themes. Remove this hook when Divi makes
159 * `top_window` iframe-aware upstream.
160 *
161 * @return void
162 */
163 function openstation_compat_divi_vb_iframe_signal() {
164 if ( is_admin() ) {
165 return;
166 }
167 if ( ! openstation_is_enabled() ) {
168 return;
169 }
170 // Bail when Divi isn't active — the inline script below is
171 // shaped entirely around Divi's frame-helpers and VB preloader.
172 // Other handlers in this file already gate on
173 // `openstation_compat_divi_is_active()`; this one was missed.
174 if ( ! openstation_compat_divi_is_active() ) {
175 return;
176 }
177 // `app_window=1` flags the inner VB iframe Divi spawns inside the
178 // `/?p=N&et_fb=1` page. The outer ("VB-top") frame is what hosts
179 // the visible preloader the user sees; the inner is where Divi
180 // mounts its React app. We only need the preloader bridge on the
181 // VB-top — the inner frame's `__Cypress__` signal is enough.
182 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only flag set by Divi itself when constructing the inner iframe.
183 $is_app_frame = isset( $_GET['app_window'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['app_window'] ) );
184 ?>
185 <script id="os-compat-divi-vb">
186 ( function () {
187 if ( window.top === window ) { return; }
188 <?php if ( $is_app_frame ) : ?>
189 // Inside Divi's own inner builder iframe. We only want to
190 // taint window.top with the Cypress flag when DM is wrapping
191 // the whole stack (3 frames deep: shell, chromeless,
192 // builder). At top-level VB flow (2 frames deep: builder-top,
193 // builder) window.parent === window.top AND window.top is
194 // the actual builder-top frame. Tainting it there would
195 // mistrain Divi's frame-helpers: it would hit the Cypress
196 // branch, see parent equals top, and resolve top_window =
197 // window (the inner self) instead of the parent. Divi then
198 // can't communicate inner-to-top and the preloader sits up
199 // forever. Bail before doing anything in this case.
200 if ( window.parent === window.top ) { return; }
201 <?php endif; ?>
202 try { window.top.__Cypress__ = window.top.__Cypress__ || true; } catch ( e ) {}
203 <?php if ( ! $is_app_frame ) : ?>
204 /*
205 * VB-top preloader bridge.
206 *
207 * Divi's `visual-builder/build/root.js` clears the preloader by
208 * removing the `et-fb-page-preloading` class from `#et-fb-app`
209 * and `#et-fb-app-body-root` in two places: its own document AND
210 * `window.top.document`. The intent is "and also clear it on the
211 * outer VB-top frame I'm rendered into." In classic admin
212 * `window.top` IS the VB-top, so that works.
213 *
214 * In a OpenStation chromeless iframe, the nesting is one deeper
215 * — the inner React app's `window.top` is the desktop shell,
216 * which has no Divi elements. Root.js cleans its own doc and
217 * no-ops on the shell, leaving THIS document's preloader stuck
218 * forever.
219 *
220 * Mirror the removal here: once the inner app-frame's
221 * `#et-fb-app` loses the preloading class (the canonical signal
222 * that Divi finished mounting), strip it from this document's
223 * `#et-fb-app` / `#et-fb-app-body-root`. Same-origin gives us
224 * direct access to the child iframe's document, so a
225 * MutationObserver on the child suffices. A 30s watchdog
226 * timeout strips the preloader even if the observer never fires
227 * (e.g. Divi's React errors out silently inside the inner
228 * frame) — better a broken builder visible than an invisible
229 * spinner forever.
230 */
231 function clearLocalPreloader() {
232 [ 'et-fb-app', 'et-fb-app-body-root' ].forEach( function ( id ) {
233 var el = document.getElementById( id );
234 if ( el ) { el.classList.remove( 'et-fb-page-preloading' ); }
235 } );
236 }
237 function bridgeAppFrame( appFrame ) {
238 var idoc = null;
239 try { idoc = appFrame.contentDocument; } catch ( e ) {}
240 if ( ! idoc ) {
241 appFrame.addEventListener( 'load', function () { bridgeAppFrame( appFrame ); }, { once: true } );
242 return;
243 }
244 function check() {
245 var inner = idoc.getElementById( 'et-fb-app' ) || idoc.getElementById( 'et-fb-app-body-root' );
246 if ( inner && ! inner.classList.contains( 'et-fb-page-preloading' ) ) {
247 clearLocalPreloader();
248 return true;
249 }
250 return false;
251 }
252 if ( check() ) { return; }
253 var mo = new MutationObserver( function () { if ( check() ) { mo.disconnect(); } } );
254 mo.observe( idoc.documentElement, { attributes: true, subtree: true, attributeFilter: [ 'class' ] } );
255 setTimeout( function () { mo.disconnect(); clearLocalPreloader(); }, 30000 );
256 }
257 function hunt() {
258 var f = document.getElementById( 'et-vb-app-frame' );
259 if ( f ) { bridgeAppFrame( f ); return; }
260 var bodyMo = new MutationObserver( function () {
261 var found = document.getElementById( 'et-vb-app-frame' );
262 if ( found ) { bodyMo.disconnect(); bridgeAppFrame( found ); }
263 } );
264 bodyMo.observe( document.documentElement, { childList: true, subtree: true } );
265 }
266 if ( document.readyState === 'loading' ) {
267 document.addEventListener( 'DOMContentLoaded', hunt );
268 } else {
269 hunt();
270 }
271 <?php endif; ?>
272 } )();
273 </script>
274 <?php
275 }
276 add_action( 'wp_head', 'openstation_compat_divi_vb_iframe_signal', 1 );
277
278 /**
279 * Iframe-side: hijack clicks on Divi's "Use Divi Builder" /
280 * "Edit With The Divi Builder" buttons and links, and hand the
281 * navigation off to the parent shell so the user can opt into a
282 * top-level browser tab for the editing session.
283 *
284 * Why we hijack instead of letting Divi navigate:
285 *
286 * Divi's Visual Builder fundamentally doesn't behave well inside
287 * OpenStation's nested iframe chain (shell -> chromeless iframe
288 * -> Divi's inner app-frame). Earlier attempts to transparently
289 * eject mid-navigation hit a chain of subtle race conditions —
290 * Divi captures `Location.prototype` references early, makes its
291 * REST save through a path our `fetch`/`XHR` wraps don't reach,
292 * and the page-leave tears down our console before any diagnostic
293 * we add survives the navigation. The honest fix is to ask the
294 * user, explicitly, whether they want to leave OpenStation for
295 * this edit session.
296 *
297 * Detection is by visible text content on the clicked element
298 * rather than by selector — Divi changes the button class across
299 * versions but the user-facing label has been stable for years.
300 * We match: "Use Divi Builder", "Use The Divi Builder", "Edit
301 * With The Divi Builder", "Edit With Divi" (case-insensitive,
302 * trimmed). The "Use Default Editor" sibling button is not in the
303 * match set, so users can still keep editing in Gutenberg.
304 *
305 * Scope: chromeless requests only, and only when Divi is active.
306 * The handler also walks every same-origin nested iframe so the
307 * Gutenberg editor canvas (when Gutenberg keeps it for non-Divi
308 * blocks) is covered.
309 *
310 * @return void
311 */
312 function openstation_compat_divi_eject_iframe_patch() {
313 if ( ! openstation_is_chromeless_request() ) {
314 return;
315 }
316 if ( ! openstation_compat_divi_is_active() ) {
317 return;
318 }
319 ?>
320 <script id="os-compat-divi-vb-handoff">
321 ( function () {
322 var BTN_TEXTS = [
323 'use divi builder',
324 'use the divi builder',
325 'edit with the divi builder',
326 'edit with divi',
327 ];
328 function matchesDiviVbButton( el ) {
329 if ( ! el || ! el.tagName ) { return false; }
330 var tag = el.tagName;
331 if ( tag !== 'BUTTON' && tag !== 'A' && tag !== 'INPUT' && tag !== 'SPAN' ) { return false; }
332 var raw = ( el.textContent || el.value || el.getAttribute( 'aria-label' ) || '' );
333 var text = String( raw ).replace( /\s+/g, ' ' ).trim().toLowerCase();
334 return BTN_TEXTS.indexOf( text ) !== -1;
335 }
336 function postHandoff( currentUrl ) {
337 try {
338 window.top.postMessage(
339 { type: 'os-divi-vb-handoff', url: String( currentUrl ) },
340 window.location.origin
341 );
342 } catch ( e ) {}
343 }
344 function onClick( e ) {
345 if ( e.defaultPrevented ) { return; }
346 if ( e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey ) { return; }
347 var el = e.target;
348 var match = null;
349 while ( el && el.nodeType === 1 ) {
350 if ( matchesDiviVbButton( el ) ) { match = el; break; }
351 el = el.parentNode;
352 }
353 if ( ! match ) { return; }
354 e.preventDefault();
355 e.stopPropagation();
356 if ( typeof e.stopImmediatePropagation === 'function' ) {
357 e.stopImmediatePropagation();
358 }
359 postHandoff( window.location.href );
360 }
361 function attachClickListener( doc ) {
362 try {
363 if ( doc.__openStationDiviHandoffAttached ) { return; }
364 doc.__openStationDiviHandoffAttached = true;
365 doc.addEventListener( 'click', onClick, true );
366 } catch ( e ) {}
367 }
368 function walkAndAttach( root ) {
369 attachClickListener( root );
370 var frames;
371 try { frames = root.querySelectorAll( 'iframe' ); }
372 catch ( e ) { return; }
373 frames.forEach( function ( iframe ) {
374 try {
375 if ( iframe.contentDocument ) { walkAndAttach( iframe.contentDocument ); }
376 } catch ( e ) {}
377 if ( iframe.__openStationDiviHandoffHooked ) { return; }
378 iframe.__openStationDiviHandoffHooked = true;
379 iframe.addEventListener( 'load', function () {
380 try { if ( iframe.contentDocument ) { walkAndAttach( iframe.contentDocument ); } } catch ( e ) {}
381 } );
382 } );
383 }
384 function bootstrap() {
385 walkAndAttach( document );
386 new MutationObserver( function () { walkAndAttach( document ); } )
387 .observe( document.documentElement, { subtree: true, childList: true } );
388 }
389 if ( document.readyState === 'loading' ) {
390 document.addEventListener( 'DOMContentLoaded', bootstrap );
391 } else {
392 bootstrap();
393 }
394 } )();
395 </script>
396 <?php
397 }
398 add_action( 'admin_head', 'openstation_compat_divi_eject_iframe_patch', 0 );
399
400 /**
401 * Parent-shell side: receive the handoff message, ask the user
402 * to confirm via `wp.os.confirm()`, and on accept navigate
403 * `window.top.location.href` to the iframe's current URL — which
404 * is the post-edit page. The user lands at top level on the same
405 * post they were editing, clicks "Use Divi Builder" again with a
406 * single browser tab, and Divi runs in its native single-frame
407 * environment.
408 *
409 * Two clicks total to enter VB, but each is deliberate. No
410 * detection magic, no race conditions, no transparent eject.
411 *
412 * Same-origin guards: the message event must originate from our
413 * own origin AND the URL we navigate to must parse back to the
414 * same origin. Foreign frames can't trigger the handoff.
415 *
416 * @return void
417 */
418 function openstation_compat_divi_eject_parent_listener() {
419 if ( ! openstation_is_shell_request() ) {
420 return;
421 }
422 if ( ! openstation_compat_divi_is_active() ) {
423 return;
424 }
425 ?>
426 <script id="os-compat-divi-vb-handoff-parent">
427 ( function () {
428 // Reshape the iframe's URL into a top-level classic-admin URL.
429 // The iframe carries `openstation_chromeless=1`, which would
430 // keep the chromeless render alive even at top level — leaving
431 // the user on what looks like the same headless Gutenberg they
432 // already had inside the window. We want a normal wp-admin page
433 // instead, so strip that flag and add `desktop_mode_classic=1`
434 // so our own `openstation_redirect_plain_admin_to_portal()` in
435 // `includes/portal.php` skips its portal-bounce for this load.
436 function handoffUrl( raw ) {
437 try {
438 var parsed = new URL( String( raw || '' ), window.location.href );
439 if ( parsed.origin !== window.location.origin ) { return null; }
440 parsed.searchParams.delete( 'openstation_chromeless' );
441 parsed.searchParams.set( 'desktop_mode_classic', '1' );
442 return parsed.toString();
443 } catch ( e ) { return null; }
444 }
445 window.addEventListener( 'message', function ( ev ) {
446 if ( ev.origin !== window.location.origin ) { return; }
447 if ( ! ev.data || ev.data.type !== 'os-divi-vb-handoff' ) { return; }
448 var url = handoffUrl( ev.data.url );
449 if ( ! url ) { return; }
450 var promptUser;
451 if ( window.wp && window.wp.os && typeof window.wp.os.confirm === 'function' ) {
452 promptUser = window.wp.os.confirm( {
453 title: 'Divi needs its own browser tab',
454 message: 'Divi\u2019s Visual Builder cannot run inside a OpenStation window \u2014 it needs the full browser tab to render and save correctly. There is no workaround on our side; Divi simply doesn\u2019t support being nested.',
455 confirmLabel: 'Open Divi in this tab',
456 hideCancel: true,
457 dismissable: true,
458 } );
459 } else {
460 // Defense-in-depth no-op. `wp.os.confirm` is
461 // reliably present on every shell page where this
462 // listener emits, so this branch is unreachable in
463 // practice. A `window.confirm` here would violate the
464 // codebase-wide "no native dialogs" rule (see CLAUDE.md);
465 // resolving false is the safer empty fallback.
466 promptUser = Promise.resolve( false );
467 }
468 Promise.resolve( promptUser ).then( function ( ok ) {
469 if ( ok ) { window.top.location.href = url; }
470 } );
471 } );
472 } )();
473 </script>
474 <?php
475 }
476 add_action( 'admin_footer', 'openstation_compat_divi_eject_parent_listener', 1 );
477
478
479 /**
480 * Detect whether Divi (theme or standalone Divi Builder plugin)
481 * is active. Used to gate both the iframe-side patcher and the
482 * parent-side listener — both no-ops on non-Divi sites.
483 *
484 * @return bool True when the Divi theme is active OR the Divi
485 * Builder plugin is active.
486 */
487 function openstation_compat_divi_is_active() {
488 $theme = wp_get_theme();
489 if ( $theme instanceof WP_Theme ) {
490 $name = (string) $theme->get( 'Name' );
491 $template = (string) $theme->get_template();
492 if ( 'Divi' === $name || 'Divi' === $template ) {
493 return true;
494 }
495 }
496 if ( function_exists( 'is_plugin_active' ) && is_plugin_active( 'divi-builder/divi-builder.php' ) ) {
497 return true;
498 }
499 return false;
500 }
501