| 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.8.3 |
| 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 |
|