| @@ -209,9 +209,26 @@ | ||
| 209 | 209 | * it runs after every other admin script — a second or more after the |
| 210 | 210 | * browser painted the "Settings saved." notice the ring is |
| 211 | 211 | * confirming. From the head it beats the body to the screen. |
| 212 | 212 | * |
| 213 | - * The parent ignores it unless that window has a submit waiting. | |
| 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. | |
| 214 | 231 | */ |
| 215 | 232 | function openstation_chromeless_navigation_ping_script() { |
| 216 | 233 | if ( ! openstation_is_chromeless_request() ) { |
| 217 | 234 | return; |
| @@ -217,9 +234,20 @@ | ||
| 217 | 234 | return; |
| 218 | 235 | } |
| 219 | 236 | |
| 220 | 237 | 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){}" | |
| 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){}' | |
| 222 | 250 | ); |
| 223 | 251 | } |
| 224 | 252 | add_action( 'admin_head', 'openstation_chromeless_navigation_ping_script', 1 ); |
| 225 | 253 | |
| @@ -327,11 +355,13 @@ | ||
| 327 | 355 | // `admin.php` calls `set_current_screen()` AFTER `admin_init`, |
| 328 | 356 | // so at probe time there is no screen yet — and Core's own |
| 329 | 357 | // enqueue callbacks (the block-editor script loader among |
| 330 | 358 | // them) read `get_current_screen()->id` unguarded. Build the |
| 331 | - // screen the real flow would have had before the hook fires. | |
| 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()`. | |
| 332 | 362 | if ( function_exists( 'set_current_screen' ) && function_exists( 'get_current_screen' ) && ! get_current_screen() ) { |
| 333 | - set_current_screen( 'admin' ); | |
| 363 | + set_current_screen( openstation_menu_refresh_probe_screen_id() ); | |
| 334 | 364 | } |
| 335 | 365 | ob_start(); |
| 336 | 366 | // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- deliberate replay of Core's own hook so handle-attached script data exists before the harvest below. |
| 337 | 367 | do_action( 'admin_enqueue_scripts', 'admin.php' ); |
| @@ -341,8 +371,35 @@ | ||
| 341 | 371 | return openstation_build_menu_payload(); |
| 342 | 372 | } |
| 343 | 373 | |
| 344 | 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 | +/** | |
| 345 | 402 | * Outputs the chromeless screen-meta bridge script. |
| 346 | 403 | * |
| 347 | 404 | * Detects Screen Options / Help panels in the iframed page and relays |
| 348 | 405 | * their availability + open/closed state to the parent desktop shell |
| @@ -514,4 +571,79 @@ | ||
| 514 | 571 | wp_enqueue_script( 'os-chromeless-bridge' ); |
| 515 | 572 | wp_add_inline_script( 'os-chromeless-bridge', $data, 'before' ); |
| 516 | 573 | } |
| 517 | 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' ); | |