PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.2
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.2
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 / shell.php

shell.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.2, at includes/render/shell.php

357 lines 12.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — Shell markup injection.
4 *
5 * Emits the `<div id="desktop-mode-shell">…</div>` skeleton at
6 * `in_admin_header @ 5`. The shell floats on top of the classic
7 * admin via `position: fixed`; the body class added by
8 * `body-classes.php` triggers the CSS that hides classic chrome.
9 *
10 * Extracted from `render.php` during the architecture-0.8.1 PHP
11 * slicing (phase 6).
12 *
13 * @package Desktop_Mode
14 * @since 0.8.1
15 */
16
17 defined( 'ABSPATH' ) || exit;
18
19
20 /**
21 * Injects the desktop shell markup into the admin page.
22 *
23 * Runs on `in_admin_header` at priority 5 so the shell renders right
24 * after the classic admin bar but before the page content. The shell
25 * floats above the classic layout via `position: fixed` in CSS; the
26 * classic sidebar, body, and footer are hidden with `body.desktop-mode-active`
27 * selectors.
28 *
29 * @since 0.1.0
30 */
31 function desktop_mode_render_shell() {
32 if ( desktop_mode_is_chromeless_request() || ! desktop_mode_is_enabled() || desktop_mode_is_classic_request() ) {
33 return;
34 }
35
36 /**
37 * Fires right before the desktop shell markup is rendered.
38 *
39 * @since 0.1.0
40 */
41 do_action( 'desktop_mode_shell_before' );
42
43 // Stamp the user's admin color scheme onto the shell root so the
44 // variables.css per-scheme selectors kick in before first paint —
45 // doing this from JS on init() would show the default palette for a
46 // frame before swapping.
47 $scheme = sanitize_html_class( get_user_option( 'admin_color' ), 'fresh' );
48 ?>
49 <div id="desktop-mode-shell" class="desktop-mode-shell" data-desktop-mode-scheme="<?php echo esc_attr( $scheme ); ?>" role="application" aria-label="<?php esc_attr_e( 'Desktop shell', 'desktop-mode' ); ?>">
50 <?php
51 /*
52 * Wallpaper layer — sits behind both the dock and the desktop
53 * area so a translucent dock bleeds through to the wallpaper
54 * (macOS pattern). Canvas-driven wallpapers mount their own
55 * DOM into this element; static CSS wallpapers just inherit
56 * the `--desktop-mode-bg` custom property the shell sets at
57 * boot. Presentational only.
58 */
59 ?>
60 <div id="desktop-mode-wallpaper" class="desktop-mode-wallpaper" aria-hidden="true"></div>
61 <div class="desktop-mode-shell__body">
62 <nav id="desktop-mode-dock" class="desktop-mode-dock" role="toolbar" aria-label="<?php esc_attr_e( 'Admin navigation', 'desktop-mode' ); ?>"></nav>
63 <div id="desktop-mode-area" class="desktop-mode-area desktop-mode-area--with-dock desktop-mode-area--booting">
64 <?php
65 /*
66 * Widget column — paints above the wallpaper but
67 * beneath windows (z-index 1 vs. windows at 100+).
68 * Hosted INSIDE `.desktop-mode-area` so scrolling the
69 * area (not that we do today) would scroll widgets
70 * with it, and so the dock naturally frames
71 * it. Empty on first render — JS (`WidgetLayer`)
72 * populates it on boot.
73 */
74 ?>
75 <aside id="desktop-mode-widgets" class="desktop-mode-widgets" aria-label="<?php esc_attr_e( 'Widgets', 'desktop-mode' ); ?>"></aside>
76 </div>
77 </div>
78 </div>
79 <?php
80 /**
81 * Fires right after the desktop shell markup has rendered.
82 *
83 * @since 0.1.0
84 */
85 do_action( 'desktop_mode_shell_after' );
86 }
87 add_action( 'in_admin_header', 'desktop_mode_render_shell', 5 );
88
89 /**
90 * Parent-shell counterpart to the chromeless bridge's stale-nonce
91 * recovery (see `chromeless-bridge.php`).
92 *
93 * Core's `wp-auth-check.js` shows `#wp-auth-check-wrap` (the dark
94 * backdrop + login iframe) when a heartbeat tick returns
95 * `wp-auth-check: false`. It only dismisses the overlay when the
96 * user re-authenticates inside *its own* sub-iframe — re-auth
97 * happening anywhere else (a chromeless iframe inside our shell,
98 * another browser tab, the classic admin in another window) leaves
99 * the parent shell stuck behind an orphaned backdrop.
100 *
101 * Beyond the backdrop, the bigger problem is that **WordPress
102 * nonces are tied to the user's session token**, and re-auth mints
103 * a fresh token. Every nonce the parent shell cached at page load
104 * (`wp.desktop.config.restNonce`, plus whatever third-party
105 * registries/widgets pulled in) was generated against the old
106 * token and is now silently rejected by `wp_verify_nonce()` /
107 * `check_ajax_referer()` — even though the auth cookie itself is
108 * valid. WP reports that as "Cookie check failed", which is
109 * misleading; the cookie is the only thing still working.
110 *
111 * Fix: on `wp-auth-check: false → true`, do a hard reload of the
112 * parent. The chromeless iframes already self-reload via their
113 * own bridge-side handler, but the parent is the only place where
114 * stale shell-wide nonces live, and there is no in-place API to
115 * swap every cached nonce across every loaded bundle + every
116 * plugin. The session-saver's `pagehide` flush writes the latest
117 * window snapshot before unload, so window positions / open
118 * windows are preserved across the reload.
119 *
120 * @since 0.18.5
121 */
122 function desktop_mode_parent_auth_check_recovery_script() {
123 if (
124 desktop_mode_is_chromeless_request()
125 || ! desktop_mode_is_enabled()
126 || desktop_mode_is_classic_request()
127 ) {
128 return;
129 }
130 $js = <<<'JS'
131 //# sourceURL=desktop-mode-parent-auth-recovery.js
132 ( function () {
133 var sawLoggedOut = false;
134
135 /* -----------------------------------------------------------------
136 * Fast-path auth-check: on 401/403 from any same-origin admin
137 * request, force `wp.heartbeat.connectNow()` instead of waiting
138 * up to 60s for the next regular tick. Same logic ships
139 * inside chromeless iframes via the bridge — this is the
140 * parent-shell counterpart for the shell's own fetches
141 * (session-save, REST registries, etc.).
142 *
143 * Debounced (5s) so a burst of failed requests doesn't fire a
144 * storm of heartbeats. URL gate skips heartbeat itself and
145 * wp-login.php so the recovery can't loop on the very request
146 * the modal authenticates with.
147 * ----------------------------------------------------------------- */
148 var authCooldownUntil = 0;
149 function maybeForceAuthCheck( status, url ) {
150 if ( status !== 401 && status !== 403 ) {
151 return;
152 }
153 try {
154 var resolved = new URL( String( url || '' ), window.location.href );
155 if ( resolved.origin !== window.location.origin ) {
156 return;
157 }
158 if (
159 resolved.pathname.indexOf( '/wp-admin/admin-ajax.php' ) !== -1
160 && /(?:^|&|\?)action=heartbeat(?:&|$)/.test( resolved.search )
161 ) {
162 return;
163 }
164 if ( resolved.pathname.indexOf( '/wp-login.php' ) !== -1 ) {
165 return;
166 }
167 } catch ( _err ) {
168 return;
169 }
170 var now = Date.now();
171 if ( now < authCooldownUntil ) {
172 return;
173 }
174 authCooldownUntil = now + 5000;
175 try {
176 if (
177 window.wp
178 && window.wp.heartbeat
179 && typeof window.wp.heartbeat.connectNow === 'function'
180 ) {
181 window.wp.heartbeat.connectNow();
182 }
183 } catch ( _err ) { /* swallow */ }
184 }
185
186 if ( typeof window.fetch === 'function' ) {
187 var origFetch = window.fetch;
188 window.fetch = function ( input, init ) {
189 var url = '';
190 if ( typeof input === 'string' ) {
191 url = input;
192 } else if ( input && typeof input === 'object' ) {
193 url = input.url || '';
194 }
195 var p;
196 try {
197 p = origFetch.apply( this, arguments );
198 } catch ( sync ) {
199 throw sync;
200 }
201 return p.then( function ( res ) {
202 try { maybeForceAuthCheck( res.status, url ); } catch ( _e ) {}
203 return res;
204 } );
205 };
206 }
207 if ( typeof XMLHttpRequest !== 'undefined' ) {
208 var origOpen = XMLHttpRequest.prototype.open;
209 XMLHttpRequest.prototype.open = function ( method, url ) {
210 try { this.__wpdAuthUrl = url; } catch ( _e ) {}
211 return origOpen.apply( this, arguments );
212 };
213 var origSend = XMLHttpRequest.prototype.send;
214 XMLHttpRequest.prototype.send = function () {
215 var xhr = this;
216 try {
217 xhr.addEventListener( 'loadend', function () {
218 try { maybeForceAuthCheck( xhr.status, xhr.__wpdAuthUrl ); } catch ( _e ) {}
219 } );
220 } catch ( _e ) {}
221 return origSend.apply( this, arguments );
222 };
223 }
224
225 function recoverFromReauth() {
226 // Strip the overlay first so the user sees the shell come
227 // back to life *before* the reload starts, instead of
228 // looking at a frozen dark backdrop while the network
229 // stalls. The reload guarantees nonces refresh.
230 try {
231 var wrap = document.getElementById( 'wp-auth-check-wrap' );
232 if ( wrap && wrap.parentNode ) {
233 wrap.parentNode.removeChild( wrap );
234 }
235 document.documentElement.classList.remove( 'wp-auth-check-show' );
236 document.body.classList.remove( 'modal-open' );
237 } catch ( _err ) { /* DOM gone — nothing useful to do */ }
238
239 // Reload every open iframe BEFORE the parent reload. Two
240 // reasons:
241 //
242 // 1. Each iframe is also showing core's wp-auth-check
243 // modal (each one runs its own heartbeat). Without
244 // this, those modals linger until each iframe's own
245 // next heartbeat tick (up to 60s) — visible as a
246 // "frozen iframe with a login modal" while the rest of
247 // the shell is interactive again.
248 //
249 // 2. If an iframe was bounced to `wp-login.php` because it
250 // made a server request while logged-out, the
251 // session-saver may have captured that URL. The parent
252 // reload would restore the iframe AT wp-login.php
253 // instead of at the original admin page. Telling each
254 // iframe to `location.reload()` directly makes the
255 // browser walk its history back through the login
256 // bounce now that cookies are fresh — the iframe lands
257 // on the page it was originally on.
258 //
259 // Same-origin only (cross-origin iframes wouldn't be ours
260 // anyway).
261 try {
262 var frames = document.querySelectorAll( 'iframe' );
263 for ( var i = 0; i < frames.length; i++ ) {
264 try {
265 // Cross-origin access throws — caught + ignored.
266 var fw = frames[ i ].contentWindow;
267 if ( fw && fw.location && typeof fw.location.reload === 'function' ) {
268 fw.location.reload();
269 }
270 } catch ( _crossOrigin ) { /* not ours */ }
271 }
272 } catch ( _err ) { /* swallow */ }
273
274 // Hard reload — the only reliable way to refresh every
275 // nonce baked into JS globals across every loaded bundle.
276 // Small delay lets the session-saver's `pagehide` write
277 // the current window snapshot AND gives the
278 // `wp-auth-check-iframe` from core a chance to relay the
279 // success postMessage out (some plugins listen for that).
280 try {
281 window.setTimeout( function () {
282 try {
283 window.location.reload();
284 } catch ( _err ) { /* swallow */ }
285 }, 250 );
286 } catch ( _err ) {
287 try { window.location.reload(); } catch ( _e ) {}
288 }
289 }
290 // Cross-iframe nudge. The chromeless bridge inside each iframe
291 // posts `desktop-mode-reauth-detected` the instant its own
292 // heartbeat sees `wp-auth-check: false → true`. Without this
293 // the parent has to wait for ITS heartbeat to tick (15s active,
294 // up to 60s idle) before recoverFromReauth fires — during which
295 // every REST call from the shell keeps returning 401 with the
296 // stale shell-wide nonce. With this, the parent's recovery
297 // starts within a frame of the iframe seeing the new cookie.
298 try {
299 window.addEventListener( 'message', function ( ev ) {
300 if ( ev.origin !== window.location.origin ) {
301 return;
302 }
303 if ( ! ev.data || typeof ev.data !== 'object' ) {
304 return;
305 }
306 if ( ev.data.type !== 'desktop-mode-reauth-detected' ) {
307 return;
308 }
309 // Recovery is idempotent (the reload-of-everything path
310 // can only fire once before the page is gone), but
311 // gate on `sawLoggedOut` anyway so a stray message
312 // from a misbehaving iframe doesn't reload the shell
313 // during a normal session.
314 if ( sawLoggedOut ) {
315 sawLoggedOut = false;
316 recoverFromReauth();
317 } else {
318 // Even if we never noticed the logout ourselves,
319 // the iframe did. Trust it and recover — the
320 // stale-nonce gap is real even when the parent
321 // dodged the auth-check modal entirely.
322 recoverFromReauth();
323 }
324 } );
325 } catch ( _err ) { /* swallow */ }
326
327 function attach() {
328 if ( ! window.jQuery ) {
329 return false;
330 }
331 window.jQuery( document ).on( 'heartbeat-tick.wpdParentAuthRecover', function ( ev, data ) {
332 if ( ! data || typeof data !== 'object' || ! ( 'wp-auth-check' in data ) ) {
333 return;
334 }
335 if ( data[ 'wp-auth-check' ] === false ) {
336 sawLoggedOut = true;
337 return;
338 }
339 if ( sawLoggedOut && data[ 'wp-auth-check' ] === true ) {
340 sawLoggedOut = false;
341 recoverFromReauth();
342 }
343 } );
344 return true;
345 }
346 if ( ! attach() ) {
347 if ( document.readyState === 'loading' ) {
348 document.addEventListener( 'DOMContentLoaded', attach, { once: true } );
349 }
350 window.addEventListener( 'load', attach, { once: true } );
351 }
352 } )();
353 JS;
354 wp_print_inline_script_tag( $js );
355 }
356 add_action( 'admin_footer', 'desktop_mode_parent_auth_check_recovery_script' );
357