PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
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
← All changes | includes/render/chromeless-bridge.php +148 -3543 1.1.21.1.10 View file →
@@ -1,9 +1,9 @@
1 1 <?php
2 2 /**
3 3 * OpenStation — Chromeless iframe bridge.
4 4 *
5 - * Two cooperative pieces emitted into chromeless admin pages:
5 + * Three cooperative pieces emitted into chromeless admin pages:
6 6 *
7 7 * - `openstation_chromeless_offset_neutralizer_script()` —
8 8 * runs on `admin_head @ 1` and rewrites positioned-element
9 9 * `top` values that match common admin-bar offsets (32px /
@@ -10,8 +10,11 @@
10 10 * 46px) to 0 inside chromeless iframes. Catches plugins that
11 11 * hardcode the admin-bar height instead of using the WP CSS
12 12 * custom property.
13 13 *
14 + * - `openstation_chromeless_navigation_ping_script()` — runs on
15 + * `admin_head @ 1` and tells the shell a navigation has landed.
16 + *
14 17 * - `openstation_chromeless_bridge_script()` — runs on
15 18 * `admin_footer` and emits the chromeless ↔ shell bridge
16 19 * script that handles screen-meta detection, command-palette
17 20 * harvesting, plugin-changed payloads, etc. The biggest
@@ -199,8 +202,29 @@
199 202 }
200 203 add_action( 'admin_head', 'openstation_chromeless_offset_neutralizer_script', 1 );
201 204
202 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 + * The parent ignores it unless that window has a submit waiting.
214 + */
215 +function openstation_chromeless_navigation_ping_script() {
216 + if ( ! openstation_is_chromeless_request() ) {
217 + return;
218 + }
219 +
220 + 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){}"
222 + );
223 +}
224 +add_action( 'admin_head', 'openstation_chromeless_navigation_ping_script', 1 );
225 +
226 +/**
203 227 * Short-circuit `admin.php?openstation_menu_refresh=1` requests with
204 228 * a tiny inline-script response that postMessages the current menu
205 229 * payload to the parent shell.
206 230 *
@@ -245,9 +269,9 @@
245 269 if ( 'admin.php' !== $pagenow ) {
246 270 return;
247 271 }
248 272
249 - $payload = openstation_build_menu_payload();
273 + $payload = openstation_menu_refresh_probe_payload();
250 274 $encoded = wp_json_encode( $payload );
251 275 if ( false === $encoded ) {
252 276 return;
253 277 }
@@ -268,8 +292,86 @@
268 292 }
269 293 add_action( 'admin_init', 'openstation_emit_menu_refresh_probe', 99 );
270 294
271 295 /**
296 + * Build the menu payload the refresh probe emits, with the script
297 + * data the harvest depends on attached first.
298 + *
299 + * `openstation_build_menu_payload()` harvests every lazy native
300 + * window's handle-attached data — `wp_localize_script` blobs and
301 + * `wp_add_inline_script` snippets — via
302 + * `openstation_resolve_script_payload()`. Modules attach that data on
303 + * `admin_enqueue_scripts` at priority ≤ 5 (the contract
304 + * `Tests_OpenStation_LazyWindowConfigPriority` pins), which holds on
305 + * every payload producer except this one: the probe short-circuits
306 + * `admin.php` on `admin_init`, long before Core would fire the
307 + * enqueue hook, so nothing was ever attached and the harvested
308 + * entries shipped with empty `scriptBefore` / `scriptL10n` arrays.
309 + *
310 + * The shell refreshes its native-window index from every payload it
311 + * receives, so one probe response silently downgraded windows the
312 + * boot payload had delivered complete — the first lazy open of WP
313 + * Explorer after a menu refresh found the WooCommerce companion with
314 + * no `openStationWooConfig`, and the store's order bands and preview
315 + * panels went dark with nothing in the console to say why.
316 + *
317 + * Replaying the hook here makes the probe's request faithful to the
318 + * real admin page it stands in for. The output buffer guards the
319 + * short-circuit response: an enqueue callback that echoes must not
320 + * beat our `header()` calls. Enqueued handles are never printed —
321 + * the probe exits before any print pipeline runs.
322 + *
323 + * @return array Menu payload, same shape as `openstation_build_menu_payload()`.
324 + */
325 +function openstation_menu_refresh_probe_payload() {
326 + if ( ! did_action( 'admin_enqueue_scripts' ) ) {
327 + // `admin.php` calls `set_current_screen()` AFTER `admin_init`,
328 + // so at probe time there is no screen yet — and Core's own
329 + // enqueue callbacks (the block-editor script loader among
330 + // them) read `get_current_screen()->id` unguarded. Build the
331 + // screen the real flow would have had before the hook fires,
332 + // in the request's own admin context — see
333 + // `openstation_menu_refresh_probe_screen_id()`.
334 + if ( function_exists( 'set_current_screen' ) && function_exists( 'get_current_screen' ) && ! get_current_screen() ) {
335 + set_current_screen( openstation_menu_refresh_probe_screen_id() );
336 + }
337 + ob_start();
338 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- deliberate replay of Core's own hook so handle-attached script data exists before the harvest below.
339 + do_action( 'admin_enqueue_scripts', 'admin.php' );
340 + ob_end_clean();
341 + }
342 +
343 + return openstation_build_menu_payload();
344 +}
345 +
346 +/**
347 + * The screen id the refresh probe builds its placeholder `WP_Screen`
348 + * from: `admin-network` in the network admin, `admin-user` in the user
349 + * admin, `admin` on a site.
350 + *
351 + * Once a screen exists, `is_network_admin()` answers from it — and a
352 + * screen built from a bare id gets its admin context from the id's
353 + * SUFFIX (`-network`, `-user`, or none for a site screen), never from
354 + * `WP_NETWORK_ADMIN`. A plain `admin` screen therefore turned a
355 + * network-admin probe into a site request for everything after it:
356 + * `self_admin_url()` resolved every network menu slug against the site
357 + * admin, and a live refresh on the network shell painted a dock whose
358 + * tiles opened site pages. Read the context here, BEFORE any screen
359 + * exists, where the answer still comes from the request itself.
360 + *
361 + * @return string Screen id carrying the request's admin context.
362 + */
363 +function openstation_menu_refresh_probe_screen_id() {
364 + if ( is_network_admin() ) {
365 + return 'admin-network';
366 + }
367 + if ( is_user_admin() ) {
368 + return 'admin-user';
369 + }
370 + return 'admin';
371 +}
372 +
373 +/**
272 374 * Outputs the chromeless screen-meta bridge script.
273 375 *
274 376 * Detects Screen Options / Help panels in the iframed page and relays
275 377 * their availability + open/closed state to the parent desktop shell
@@ -336,3539 +438,8 @@
336 438 if ( false === $content_identity_json ) {
337 439 $content_identity_json = 'null';
338 440 }
339 441
340 - // Emit via wp_print_inline_script_tag so CSP nonces and `<script>`
341 - // attribute hygiene go through Core rather than being hand-rolled.
342 - $js = <<<'JS'
343 -//# sourceURL=os-chromeless-bridge.js
344 -( function() {
345 - // Escape hatch: a chromeless page is *normally* only meant to live
346 - // inside a openstation window iframe. If the top window IS this
347 - // page, the user usually ended up here by accident — bookmarked
348 - // it, followed a stale link, or got stranded by a bad portal
349 - // redirect. Without an admin bar there's no toggle to turn
350 - // OpenStation off, so strip the chromeless flag and reload as
351 - // classic admin. That puts the admin bar back and lets the user
352 - // decide what to do.
353 - //
354 - // Unless something is deliberately HOSTING this page top-level.
355 - // `window.openStationChromelessHost` is how an embedder says "this
356 - // is not an accident, and I provide the way out" — the native
357 - // desktop host sets it on windows a user set free, where closing
358 - // the OS window is the way back. Rescuing those would strip the
359 - // flag, reload as classic, bounce through the portal, and leave a
360 - // whole second desktop inside a window that was meant to hold one
361 - // screen.
362 - //
363 - // It has to be a JS global rather than a query flag: a query flag
364 - // is lost on the first in-page navigation, and the host would stop
365 - // recognising its own window the moment the user clicked a link.
366 - if ( ! window.parent || window.parent === window ) {
367 - if ( ! window.openStationChromelessHost ) {
368 - try {
369 - var here = new URL( window.location.href );
370 - if ( here.searchParams.has( 'openstation_chromeless' ) ) {
371 - here.searchParams.delete( 'openstation_chromeless' );
372 - here.searchParams.delete( 'desktop_mode_portal' );
373 - window.location.replace( here.toString() );
374 - }
375 - } catch ( err ) {
376 - /* URL parse failure — let the broken state stand rather than
377 - * navigate somewhere worse. */
378 - }
379 - }
380 - // Either way the rest of the bridge is skipped: every feature
381 - // below posts to `window.parent`, and there isn't one.
382 - return;
383 - }
384 -
385 - /*
386 - * Content-identity announcement. The server resolved which object
387 - * this page shows (post / comment / attachment, plus the root post
388 - * a child belongs to) while it still had real admin context; hand
389 - * it to the parent's relations engine. Deliberately posted even
390 - * when the identity is null — a full-page navigation away from an
391 - * identified screen must CLEAR the stale identity, and every
392 - * navigation re-runs admin_footer, so this doubles as the
393 - * re-announce-on-navigate path.
394 - *
395 - * Posted FIRST, right after the top-frame escape hatch, because it
396 - * depends on nothing else in this script: a page-specific runtime
397 - * failure in any of the feature blocks below (screen-meta harvest,
398 - * command scan, link interceptor, …) must not cost the shell its
399 - * window relations. The `os-ready` signal intentionally
400 - * stays LAST — it means "every listener below is wired".
401 - */
402 - try {
403 - window.parent.postMessage(
404 - {
405 - type: 'os-content-identity',
406 - identity: /*__OPENSTATION_CONTENT_IDENTITY__*/
407 - },
408 - window.location.origin
409 - );
410 - } catch ( _err ) { /* parent gone or cross-origin */ }
411 -
412 - /*
413 - * Editor save-watcher — keeps the identity fresh across block-editor
414 - * saves. Gutenberg saves over REST without a page navigation, so the
415 - * announcement above (rebuilt only on admin_footer) goes stale the
416 - * moment the user adds a category, links a post, or sets a featured
417 - * image — the parent's Related menu and window ties would show the
418 - * pre-save state until a manual reload. After every real
419 - * (non-autosave) save completes, refetch a server-recomputed
420 - * identity from `desktop-mode/v1/content-identity` and re-announce
421 - * it; the parent engine diffs and repaints. The classic editor
422 - * reloads the page on save, which re-runs the announcement
423 - * naturally — this block never engages there (no `core/editor`
424 - * store on the page).
425 - */
426 - window.addEventListener( 'load', function () {
427 - try {
428 - var wpg = window.wp;
429 - if ( ! wpg || ! wpg.data || ! wpg.apiFetch || typeof wpg.data.select !== 'function' ) {
430 - return;
431 - }
432 - var editor = wpg.data.select( 'core/editor' );
433 - if (
434 - ! editor ||
435 - typeof editor.isSavingPost !== 'function' ||
436 - typeof editor.getCurrentPostId !== 'function'
437 - ) {
438 - return;
439 - }
440 - var wasSaving = false;
441 - var wasNew = false;
442 - var inFlight = false;
443 - wpg.data.subscribe( function () {
444 - var saving =
445 - editor.isSavingPost() &&
446 - ! ( editor.isAutosavingPost && editor.isAutosavingPost() );
447 - if ( saving && ! wasSaving ) {
448 - // Capture "is this the first real save?" on the tick
449 - // where saving STARTS — after the save completes the
450 - // post is no longer new and the flag reads false.
451 - wasNew = !! (
452 - editor.isEditedPostNew && editor.isEditedPostNew()
453 - );
454 - }
455 - var finished = wasSaving && ! saving;
456 - wasSaving = saving;
457 - if ( ! finished || inFlight ) {
458 - return;
459 - }
460 - if (
461 - editor.didPostSaveRequestSucceed &&
462 - ! editor.didPostSaveRequestSucceed()
463 - ) {
464 - return;
465 - }
466 - var postId = editor.getCurrentPostId();
467 - if ( ! postId ) {
468 - return;
469 - }
470 -
471 - /*
472 - * Announce the save as a cross-window content-change
473 - * broadcast. Gutenberg saves over REST with no
474 - * navigation, so the server-side chromeless-footer
475 - * emitter (includes/content-changes.php) never runs
476 - * here — this is the only instant path for block-editor
477 - * saves. The parent's broadcast receiver fans it out;
478 - * list windows showing this post type refresh.
479 - */
480 - if ( editor.getCurrentPostType ) {
481 - try {
482 - window.parent.postMessage(
483 - {
484 - type: 'os-broadcast',
485 - topic:
486 - 'os.' +
487 - editor.getCurrentPostType() +
488 - '.changed',
489 - payload: {
490 - source: 'editor',
491 - action: wasNew ? 'created' : 'updated',
492 - ids: [ postId ],
493 - },
494 - },
495 - window.location.origin
496 - );
497 - } catch ( _err ) { /* parent gone */ }
498 - }
499 - inFlight = true;
500 - wpg
501 - .apiFetch( {
502 - path: '/desktop-mode/v1/content-identity?post=' + postId,
503 - } )
504 - .then( function ( res ) {
505 - if ( res && res.identity ) {
506 - window.parent.postMessage(
507 - {
508 - type: 'os-content-identity',
509 - identity: res.identity,
510 - },
511 - window.location.origin
512 - );
513 - }
514 - } )
515 - .catch( function () {
516 - /* Transient — the next save retries. */
517 - } )
518 - .finally( function () {
519 - inFlight = false;
520 - } );
521 - } );
522 - } catch ( _err ) {
523 - /* Editor stores absent or shaped differently — nothing to watch. */
524 - }
525 - } );
526 -
527 - /*
528 - * Observability — iframe error + network capture.
529 - *
530 - * Everything admin-interesting (REST failures from Gutenberg,
531 - * admin-ajax 500s, plugin console warnings) fires INSIDE the
532 - * iframe whose parent is the desktop shell. Without relaying
533 - * those events to the shell, monitor / debug widgets would only
534 - * ever see the shell's own errors — the smallest, least-
535 - * interesting surface in the whole admin.
536 - *
537 - * Two listeners and two wrappers land here:
538 - *
539 - * - `error` + `unhandledrejection` on window → postMessage
540 - * `os-iframe-error`. Parent dispatches `HOOKS.
541 - * IFRAME_ERROR`.
542 - * - `fetch` + `XMLHttpRequest` are wrapped so every completed
543 - * request (including failures) posts
544 - * `os-iframe-network` with `{ method, url, status,
545 - * duration, failed }`. Parent dispatches `HOOKS.
546 - * IFRAME_NETWORK_COMPLETED`.
547 - *
548 - * Privacy: request / response bodies are NEVER captured — only
549 - * method, URL, status, duration. Monitor widgets that want the
550 - * full payload must ship their own deeper wrapper (at which
551 - * point they own the consent conversation).
552 - */
553 - try {
554 - window.addEventListener( 'error', function ( e ) {
555 - try {
556 - window.parent.postMessage( {
557 - type: 'os-iframe-error',
558 - kind: 'error',
559 - message: e && e.message ? String( e.message ) : '',
560 - filename: e && e.filename ? String( e.filename ) : null,
561 - lineno: e && typeof e.lineno === 'number' ? e.lineno : null,
562 - colno: e && typeof e.colno === 'number' ? e.colno : null,
563 - stack: e && e.error && e.error.stack ? String( e.error.stack ) : null
564 - }, window.location.origin );
565 - } catch ( _err ) { /* swallow: don't let the relay compound the error */ }
566 - } );
567 -
568 - window.addEventListener( 'unhandledrejection', function ( e ) {
569 - try {
570 - var reason = e && 'reason' in e ? e.reason : null;
571 - var message = '';
572 - var stack = null;
573 - if ( reason instanceof Error ) {
574 - message = reason.message;
575 - stack = reason.stack || null;
576 - } else if ( reason !== null && reason !== undefined ) {
577 - try { message = String( reason ); } catch ( _s ) { message = '[unstringifiable]'; }
578 - }
579 - window.parent.postMessage( {
580 - type: 'os-iframe-error',
581 - kind: 'unhandledrejection',
582 - message: message,
583 - filename: null,
584 - lineno: null,
585 - colno: null,
586 - stack: stack
587 - }, window.location.origin );
588 - } catch ( _err ) { /* swallow */ }
589 - } );
590 -
591 - // Devtools instrumentation slot — populated by
592 - // `os-instrument-set` messages from the parent shell.
593 - // Mutable: parent overwrites the whole object on every change
594 - // (header add/remove, observe toggle).
595 - //
596 - // Headers: { name: 'value' } — already pre-merged by the parent
597 - // (RFC 7230 §3.2.2 join applied there).
598 - // Observe: when true, network reports include request +
599 - // response headers; otherwise only the privacy-conscious
600 - // summary travels parent-bound.
601 - window.__wpdInstrument = window.__wpdInstrument || { headers: {}, observe: false };
602 - try {
603 - window.addEventListener( 'message', function ( ev ) {
604 - if ( ev.origin !== window.location.origin || ev.source !== window.parent ) {
605 - return;
606 - }
607 - var d = ev && ev.data;
608 - if ( ! d || typeof d !== 'object' || d.type !== 'os-instrument-set' ) {
609 - return;
610 - }
611 - window.__wpdInstrument = {
612 - headers: d.headers && typeof d.headers === 'object' ? d.headers : {},
613 - observe: !! d.observe
614 - };
615 - } );
616 - } catch ( _err ) { /* swallow — instrumentation is best-effort */ }
617 -
618 - var osReportNetwork = function ( method, url, status, duration, failed, extra ) {
619 - try {
620 - var msg = {
621 - type: 'os-iframe-network',
622 - method: String( method || 'GET' ).toUpperCase(),
623 - url: String( url || '' ),
624 - status: typeof status === 'number' ? status : 0,
625 - duration: typeof duration === 'number' ? duration : 0,
626 - failed: !! failed
627 - };
628 - if ( extra && window.__wpdInstrument && window.__wpdInstrument.observe ) {
629 - if ( extra.requestHeaders ) {
630 - msg.requestHeaders = extra.requestHeaders;
631 - }
632 - if ( extra.responseHeaders ) {
633 - msg.responseHeaders = extra.responseHeaders;
634 - }
635 - }
636 - window.parent.postMessage( msg, window.location.origin );
637 - } catch ( _err ) { /* swallow */ }
638 - };
639 -
640 - // Activity reporting — the window's status ring.
641 - //
642 - // `os-iframe-network` above fires on COMPLETION only, which is
643 - // enough for the devtools panel and useless for an indicator:
644 - // a ring that can only be told "it finished" never shows the
645 - // part the user is waiting through. These two messages bracket
646 - // the request instead, and the parent reference-counts them
647 - // the same way `wp.os.fetch` does for native windows.
648 - //
649 - // Reads do NOT count. The ring answers one question — "did my
650 - // change go through?" — and a GET has no "through": nothing was
651 - // changed, so nothing can have failed to change. In an admin
652 - // page most GETs are the page's own housekeeping (list-table
653 - // refreshes, dashboard widgets, autosave checks, media queries)
654 - // that the user never asked about and shouldn't be made to
655 - // watch. Mutations are the requests with a question attached.
656 - //
657 - // HEAD and OPTIONS go with GET: a probe and a preflight are
658 - // even further from a change than a read is.
659 - //
660 - // QUERY too, and it is the one that needs saying out loud: it
661 - // carries a BODY, so every "does it have a payload?" heuristic
662 - // mistakes it for a write. It is a safe, idempotent read — a
663 - // GET whose parameters wouldn't fit in a URL — so it belongs
664 - // here with the rest of them. Listed ahead of the spec landing
665 - // on purpose: a method the shell has never heard of arriving in
666 - // a Core release should not start lighting rings.
667 - var osIsReadRequest = function ( method ) {
668 - var m = String( method || 'GET' ).toUpperCase();
669 - return 'GET' === m || 'HEAD' === m || 'OPTIONS' === m || 'QUERY' === m;
670 - };
671 -
672 - var osIsBackgroundRequest = function ( url, body ) {
673 - // WordPress Heartbeat is a poll the user did not initiate,
674 - // on a timer, forever. Reporting it would light the ring
675 - // on every open window every 15 seconds and flash a
676 - // "Saved" check for a save nobody made — the framework's
677 - // own background pings pass `silent: true` for exactly
678 - // this reason. The action rides in the POST body, not the
679 - // URL, so both are checked.
680 - try {
681 - if ( String( url || '' ).indexOf( 'action=heartbeat' ) !== -1 ) {
682 - return true;
683 - }
684 - if ( typeof body === 'string' && body.indexOf( 'action=heartbeat' ) !== -1 ) {
685 - return true;
686 - }
687 - if ( body && typeof body.get === 'function' && body.get( 'action' ) === 'heartbeat' ) {
688 - return true;
689 - }
690 - } catch ( _bgErr ) { /* unreadable body — treat as foreground */ }
691 - return false;
692 - };
693 -
694 - var osActivityBegin = function ( method, url, body ) {
695 - if ( osIsReadRequest( method ) || osIsBackgroundRequest( url, body ) ) {
696 - return false;
697 - }
698 - try {
699 - window.parent.postMessage(
700 - { type: 'os-iframe-activity', phase: 'start' },
701 - window.location.origin
702 - );
703 - } catch ( _sErr ) { /* swallow — instrumentation is best-effort */ }
704 - return true;
705 - };
706 -
707 - // `tracked` is the value `osActivityBegin` returned, so a
708 - // request that was never counted can never decrement — an
709 - // unbalanced end would settle the ring while other requests
710 - // are still in flight.
711 - var osActivityEnd = function ( tracked, failed, status ) {
712 - if ( ! tracked ) {
713 - return;
714 - }
715 - try {
716 - window.parent.postMessage(
717 - {
718 - type: 'os-iframe-activity',
719 - phase: 'end',
720 - failed: !! failed,
721 - status: typeof status === 'number' ? status : 0
722 - },
723 - window.location.origin
724 - );
725 - } catch ( _eErr ) { /* swallow */ }
726 - };
727 -
728 - // Helper — when an admin-side request returns 401/403 the
729 - // session is most likely toast. Don't wait up to 60s for the
730 - // next heartbeat tick to surface core's auth-check modal —
731 - // force an immediate tick. `wp.heartbeat.connectNow()` is
732 - // safe to call repeatedly; we still debounce to avoid storms
733 - // when many requests fail at once. Same-origin gate keeps us
734 - // out of third-party 403s. The URL gate avoids looping on
735 - // heartbeat itself (heartbeat shouldn't 403 — but if it does
736 - // the recursive connectNow would not help anyway).
737 - var osAuthCheckCooldownUntil = 0;
738 - var osMaybeForceAuthCheck = function ( status, url ) {
739 - if ( status !== 401 && status !== 403 ) {
740 - return;
741 - }
742 - var urlStr = String( url || '' );
743 - if ( ! urlStr ) {
744 - return;
745 - }
746 - // Cross-origin URLs aren't ours to interpret.
747 - try {
748 - var resolved = new URL( urlStr, window.location.href );
749 - if ( resolved.origin !== window.location.origin ) {
750 - return;
751 - }
752 - // Skip heartbeat to avoid recursion. Skip wp-login
753 - // because the login iframe itself returns 4xx during
754 - // the auth handshake and we don't want to retrigger.
755 - if (
756 - resolved.pathname.indexOf( '/wp-admin/admin-ajax.php' ) !== -1
757 - && /(?:^|&|\?)action=heartbeat(?:&|$)/.test( resolved.search )
758 - ) {
759 - return;
760 - }
761 - if ( resolved.pathname.indexOf( '/wp-login.php' ) !== -1 ) {
762 - return;
763 - }
764 - } catch ( _err ) {
765 - return;
766 - }
767 - var now = Date.now();
768 - if ( now < osAuthCheckCooldownUntil ) {
769 - return;
770 - }
771 - osAuthCheckCooldownUntil = now + 5000;
772 - try {
773 - if (
774 - window.wp
775 - && window.wp.heartbeat
776 - && typeof window.wp.heartbeat.connectNow === 'function'
777 - ) {
778 - window.wp.heartbeat.connectNow();
779 - }
780 - } catch ( _err ) { /* swallow */ }
781 - };
782 -
783 - // Helper — convert an arbitrary `init.headers` shape into a
784 - // plain `{ name: value }` map so the instrument layer can
785 - // merge contributed headers without caring whether the caller
786 - // passed a Headers, an array of pairs, or a plain object.
787 - var osHeadersToObject = function ( h ) {
788 - var out = {};
789 - if ( ! h ) {
790 - return out;
791 - }
792 - if ( typeof Headers !== 'undefined' && h instanceof Headers ) {
793 - try {
794 - h.forEach( function ( v, k ) { out[ k ] = v; } );
795 - } catch ( _e ) { /* swallow */ }
796 - return out;
797 - }
798 - if ( Array.isArray( h ) ) {
799 - for ( var i = 0; i < h.length; i++ ) {
800 - if ( h[ i ] && h[ i ].length >= 2 ) {
801 - out[ h[ i ][ 0 ] ] = h[ i ][ 1 ];
802 - }
803 - }
804 - return out;
805 - }
806 - if ( typeof h === 'object' ) {
807 - for ( var k in h ) {
808 - if ( Object.prototype.hasOwnProperty.call( h, k ) ) {
809 - out[ k ] = h[ k ];
810 - }
811 - }
812 - }
813 - return out;
814 - };
815 -
816 - // Helper — snapshot the contributed-header set at request time.
817 - // Header values can theoretically come and go between requests
818 - // (parent ref-counts contributions) so we read fresh on every
819 - // call rather than caching at wrap time.
820 - var osContributedHeaders = function () {
821 - var inst = window.__wpdInstrument || {};
822 - var headers = inst.headers || {};
823 - var out = {};
824 - for ( var k in headers ) {
825 - if ( Object.prototype.hasOwnProperty.call( headers, k ) && typeof headers[ k ] === 'string' ) {
826 - out[ k ] = headers[ k ];
827 - }
828 - }
829 - return out;
830 - };
831 -
832 - // Wrap fetch. Called AFTER `admin_footer` runs — plugin code
833 - // using fetch during synchronous page boot (rare in wp-admin)
834 - // bypasses this, but lazy calls (the common case) are captured.
835 - //
836 - // Two layers of behavior:
837 - //
838 - // - Always: timing + status reporting (the original
839 - // observability contract).
840 - // - When `__wpdInstrument.headers` is non-empty: merge those
841 - // headers into the request before dispatch so devtools can
842 - // tag every outgoing call without each plugin reinventing
843 - // a fetch wrapper.
844 - // - When `__wpdInstrument.observe`: also relay request +
845 - // response headers in the parent-bound network message.
846 - if ( typeof window.fetch === 'function' ) {
847 - var osOrigFetch = window.fetch;
848 - window.fetch = function ( input, init ) {
849 - var start = ( typeof performance !== 'undefined' && performance.now )
850 - ? performance.now()
851 - : Date.now();
852 - var method = 'GET';
853 - var url = '';
854 - if ( typeof input === 'string' ) {
855 - url = input;
856 - if ( init && typeof init.method === 'string' ) {
857 - method = init.method;
858 - }
859 - } else if ( input && typeof input === 'object' ) {
860 - url = input.url || '';
861 - method = ( input.method || ( init && init.method ) || 'GET' );
862 - }
863 -
864 - // Header contribution + capture. Build a single
865 - // `Headers` instance so contributed values overwrite /
866 - // stack predictably regardless of the caller's input
867 - // shape, then re-attach to a cloned init.
868 - var contributed = osContributedHeaders();
869 - var observe = window.__wpdInstrument && window.__wpdInstrument.observe;
870 - var requestHeaders = null;
871 - var hasContributed = false;
872 - for ( var ck in contributed ) {
873 - if ( Object.prototype.hasOwnProperty.call( contributed, ck ) ) {
874 - hasContributed = true;
875 - break;
876 - }
877 - }
878 - if ( hasContributed || observe ) {
879 - var existing = osHeadersToObject( init && init.headers );
880 - if ( input && typeof input === 'object' && input.headers ) {
881 - var fromReq = osHeadersToObject( input.headers );
882 - for ( var rk in fromReq ) {
883 - if ( Object.prototype.hasOwnProperty.call( fromReq, rk ) && ! ( rk in existing ) ) {
884 - existing[ rk ] = fromReq[ rk ];
885 - }
886 - }
887 - }
888 - for ( var ck2 in contributed ) {
889 - if ( Object.prototype.hasOwnProperty.call( contributed, ck2 ) ) {
890 - existing[ ck2 ] = contributed[ ck2 ];
891 - }
892 - }
893 - if ( hasContributed ) {
894 - init = init ? Object.assign( {}, init ) : {};
895 - init.headers = existing;
896 - arguments[ 1 ] = init;
897 - }
898 - if ( observe ) {
899 - requestHeaders = existing;
900 - }
901 - }
902 -
903 - var tracked = osActivityBegin( method, url, ( init && init.body ) || ( input && input.body ) );
904 -
905 - var promise;
906 - try {
907 - promise = osOrigFetch.apply( this, arguments );
908 - } catch ( sync ) {
909 - osReportNetwork( method, url, 0, 0, true, requestHeaders ? { requestHeaders: requestHeaders } : null );
910 - osActivityEnd( tracked, true, 0 );
911 - throw sync;
912 - }
913 - return promise.then(
914 - function ( res ) {
915 - var dur = ( ( typeof performance !== 'undefined' && performance.now )
916 - ? performance.now()
917 - : Date.now() ) - start;
918 - var extra = null;
919 - if ( requestHeaders ) {
920 - extra = { requestHeaders: requestHeaders };
921 - try {
922 - var rh = {};
923 - if ( res && res.headers && typeof res.headers.forEach === 'function' ) {
924 - res.headers.forEach( function ( v, k ) { rh[ k ] = v; } );
925 - }
926 - extra.responseHeaders = rh;
927 - } catch ( _hErr ) { /* swallow */ }
928 - }
929 - osReportNetwork( method, url, res.status, Math.round( dur ), ! res.ok, extra );
930 - // `fetch` resolves for 4xx / 5xx, so the ring
931 - // settles on `res.ok` and not on the promise.
932 - osActivityEnd( tracked, ! res.ok, res.status );
933 - osMaybeForceAuthCheck( res.status, url );
934 - return res;
935 - },
936 - function ( err ) {
937 - var dur = ( ( typeof performance !== 'undefined' && performance.now )
938 - ? performance.now()
939 - : Date.now() ) - start;
940 - osReportNetwork( method, url, 0, Math.round( dur ), true, requestHeaders ? { requestHeaders: requestHeaders } : null );
941 - osActivityEnd( tracked, true, 0 );
942 - throw err;
943 - }
944 - );
945 - };
946 - }
947 -
948 - // Wrap XHR — admin-ajax runs through jQuery which runs through
949 - // XHR, so fetch-only instrumentation would miss most of the
950 - // legacy admin surface. Record method + URL on open; fire on
951 - // loadend regardless of success / failure.
952 - //
953 - // Header contribution layer: `setRequestHeader` after open() but
954 - // before send() — that's the only window the spec allows. The
955 - // caller's own headers are tracked so observation can include
956 - // them alongside the contributed ones.
957 - if ( typeof XMLHttpRequest !== 'undefined' ) {
958 - var osOrigOpen = XMLHttpRequest.prototype.open;
959 - var osOrigSend = XMLHttpRequest.prototype.send;
960 - var osOrigSetHeader = XMLHttpRequest.prototype.setRequestHeader;
961 - XMLHttpRequest.prototype.open = function ( method, url ) {
962 - try {
963 - this.__wpdMethod = method;
964 - this.__wpdUrl = url;
965 - this.__wpdReqHeaders = {};
966 - } catch ( _err ) { /* frozen instance — skip */ }
967 - return osOrigOpen.apply( this, arguments );
968 - };
969 - XMLHttpRequest.prototype.setRequestHeader = function ( name, value ) {
970 - try {
971 - if ( ! this.__wpdReqHeaders ) {
972 - this.__wpdReqHeaders = {};
973 - }
974 - this.__wpdReqHeaders[ name ] = value;
975 - } catch ( _err ) { /* swallow */ }
976 - return osOrigSetHeader.apply( this, arguments );
977 - };
978 - XMLHttpRequest.prototype.send = function ( body ) {
979 - var xhr = this;
980 - var start = ( typeof performance !== 'undefined' && performance.now )
981 - ? performance.now()
982 - : Date.now();
983 - // The body is where an admin-ajax action name lives,
984 - // and the action name is how Heartbeat is recognised.
985 - var tracked = osActivityBegin( xhr.__wpdMethod, xhr.__wpdUrl, body );
986 -
987 - // Apply contributed headers right before send. Doing it
988 - // here rather than in open() means contributions added
989 - // after open() (e.g. in async-built request flows) still
990 - // land on the wire.
991 - var contributed = osContributedHeaders();
992 - var observe = window.__wpdInstrument && window.__wpdInstrument.observe;
993 - for ( var hk in contributed ) {
994 - if ( Object.prototype.hasOwnProperty.call( contributed, hk ) ) {
995 - try {
996 - osOrigSetHeader.call( xhr, hk, contributed[ hk ] );
997 - if ( ! xhr.__wpdReqHeaders ) {
998 - xhr.__wpdReqHeaders = {};
999 - }
1000 - xhr.__wpdReqHeaders[ hk ] = contributed[ hk ];
1001 - } catch ( _hErr ) { /* `setRequestHeader` rejects forbidden names — skip */ }
1002 - }
1003 - }
1004 -
1005 - var fire = function () {
1006 - var dur = ( ( typeof performance !== 'undefined' && performance.now )
1007 - ? performance.now()
1008 - : Date.now() ) - start;
1009 - var extra = null;
1010 - if ( observe ) {
1011 - extra = {
1012 - requestHeaders: xhr.__wpdReqHeaders || {}
1013 - };
1014 - try {
1015 - var raw = xhr.getAllResponseHeaders ? xhr.getAllResponseHeaders() : '';
1016 - var resHeaders = {};
1017 - if ( raw && typeof raw === 'string' ) {
1018 - var lines = raw.trim().split( /[\r\n]+/ );
1019 - for ( var li = 0; li < lines.length; li++ ) {
1020 - var idx = lines[ li ].indexOf( ':' );
1021 - if ( idx > 0 ) {
1022 - resHeaders[ lines[ li ].slice( 0, idx ).trim() ] = lines[ li ].slice( idx + 1 ).trim();
1023 - }
1024 - }
1025 - }
1026 - extra.responseHeaders = resHeaders;
1027 - } catch ( _rErr ) { /* swallow */ }
1028 - }
1029 - var failed = xhr.status === 0 || xhr.status >= 400;
1030 - osReportNetwork(
1031 - xhr.__wpdMethod,
1032 - xhr.__wpdUrl,
1033 - xhr.status,
1034 - Math.round( dur ),
1035 - failed,
1036 - extra
1037 - );
1038 - osActivityEnd( tracked, failed, xhr.status );
1039 - osMaybeForceAuthCheck( xhr.status, xhr.__wpdUrl );
1040 - };
1041 - try {
1042 - xhr.addEventListener( 'loadend', fire );
1043 - } catch ( _err ) { /* swallow */ }
1044 - return osOrigSend.apply( this, arguments );
1045 - };
1046 - }
1047 -
1048 - // Wrap sendBeacon — used by analytics + telemetry. The Beacon
1049 - // API doesn't accept headers (the entire point of beacons is
1050 - // minimal payload + best-effort delivery). When devtools have
1051 - // contributed headers we silently fall back to fetch with
1052 - // `keepalive: true`, which is the closest semantic match —
1053 - // guaranteed POST + same fire-and-forget intent + custom headers
1054 - // allowed. Without contributions we just relay the call.
1055 - if ( typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function' ) {
1056 - var osOrigBeacon = navigator.sendBeacon.bind( navigator );
1057 - navigator.sendBeacon = function ( url, data ) {
1058 - var contributed = osContributedHeaders();
1059 - var hasContributed = false;
1060 - for ( var ck in contributed ) {
1061 - if ( Object.prototype.hasOwnProperty.call( contributed, ck ) ) {
1062 - hasContributed = true;
1063 - break;
1064 - }
1065 - }
1066 - var start = ( typeof performance !== 'undefined' && performance.now )
1067 - ? performance.now()
1068 - : Date.now();
1069 - if ( ! hasContributed ) {
1070 - var ok = false;
1071 - try { ok = !! osOrigBeacon( url, data ); } catch ( _e ) { ok = false; }
1072 - osReportNetwork( 'POST', url, ok ? 200 : 0, 0, ! ok );
1073 - return ok;
1074 - }
1075 - try {
1076 - var observe = window.__wpdInstrument && window.__wpdInstrument.observe;
1077 - var headers = {};
1078 - for ( var hk2 in contributed ) {
1079 - if ( Object.prototype.hasOwnProperty.call( contributed, hk2 ) ) {
1080 - headers[ hk2 ] = contributed[ hk2 ];
1081 - }
1082 - }
1083 - window.fetch( url, {
1084 - method: 'POST',
1085 - body: data,
1086 - keepalive: true,
1087 - credentials: 'same-origin',
1088 - headers: headers
1089 - } ).then(
1090 - function ( res ) {
1091 - var dur = ( ( typeof performance !== 'undefined' && performance.now )
1092 - ? performance.now()
1093 - : Date.now() ) - start;
1094 - osReportNetwork( 'POST', url, res.status, Math.round( dur ), ! res.ok, observe ? { requestHeaders: headers } : null );
1095 - },
1096 - function () {
1097 - var dur = ( ( typeof performance !== 'undefined' && performance.now )
1098 - ? performance.now()
1099 - : Date.now() ) - start;
1100 - osReportNetwork( 'POST', url, 0, Math.round( dur ), true, observe ? { requestHeaders: headers } : null );
1101 - }
1102 - );
1103 - return true;
1104 - } catch ( _bErr ) {
1105 - return false;
1106 - }
1107 - };
1108 - }
1109 - } catch ( _err ) {
1110 - /* Whole observability block is best-effort. If something in
1111 - * the environment disagrees (frozen prototypes, CSP blocking
1112 - * postMessage, etc.) we don't want to tank the rest of the
1113 - * chromeless bridge. */
1114 - }
1115 -
1116 - /*
1117 - * Menu-changed signal.
1118 - *
1119 - * The shell's dock is built from `$menu` at page-load time and
1120 - * then frozen — the iframe reload that follows plugin
1121 - * activation / deactivation / installation doesn't tell the
1122 - * parent the admin menu just mutated. This handler fires inside
1123 - * the iframe that JUST LOADED plugins.php (or a sibling menu-
1124 - * affecting page) and hands the parent a fresh payload the PHP
1125 - * side built server-side from the live $menu globals.
1126 - *
1127 - * Why not a REST roundtrip: plugins commonly gate their
1128 - * `admin_menu` registration on `is_admin()` evaluated AT PLUGIN
1129 - * LOAD. REST requests don't define `WP_ADMIN` at plugin-load
1130 - * time, so those plugins never register and a REST-context
1131 - * bootstrap can't retroactively make them. By capturing the
1132 - * payload here, inside a real admin context, we get the
1133 - * authoritative post-activation state that any REST endpoint
1134 - * would miss.
1135 - *
1136 - * Covered pages:
1137 - * - plugins.php — activate, deactivate, bulk, delete.
1138 - * - plugin-install.php — install new, install-and-activate.
1139 - * - update.php — update / install handler (install-
1140 - * plugin + upload-plugin actions).
1141 - * - themes.php — theme switch (rare but can add menus).
1142 - */
1143 - var __OPENSTATION_MENU_PAYLOAD__ = /*__OPENSTATION_MENU_PAYLOAD__*/;
1144 - var __OPENSTATION_MENU_SIG__ = /*__OPENSTATION_MENU_SIG__*/;
1145 - /*
1146 - * Icon harvest from the iframe's authoritative #adminmenu.
1147 - *
1148 - * The server-side payload only knows what the plugin set on
1149 - * $menu[$i][6]. Plugins that register their icon with 'none' /
1150 - * 'div' and paint it via a CSS rule on `#adminmenu .menu-icon-X`
1151 - * (All in One WP Migration, plus a long tail of older plugins)
1152 - * end up serialized with the gear fallback.
1153 - *
1154 - * On a regular page load the parent shell's resolveIcon() falls
1155 - * back to the parent's hidden #adminmenu DOM and reads the icon
1156 - * from there — but on a live activation the parent's #adminmenu
1157 - * is stale (it was rendered before the plugin existed). This
1158 - * iframe just rendered plugins.php in real admin context, so its
1159 - * own #adminmenu DOM IS authoritative; harvest each menu item's
1160 - * resolved icon here and patch the dockItems before postMessage.
1161 - */
1162 - try {
1163 - if (
1164 - __OPENSTATION_MENU_PAYLOAD__
1165 - && Array.isArray( __OPENSTATION_MENU_PAYLOAD__.dockItems )
1166 - ) {
1167 - var __wpdAdminMenu = document.getElementById( 'adminmenu' );
1168 - if ( __wpdAdminMenu ) {
1169 - var __wpdHarvest = {};
1170 - var __wpdLinks = __wpdAdminMenu.querySelectorAll( 'li.menu-top > a' );
1171 - for ( var __wpdLi = 0; __wpdLi < __wpdLinks.length; __wpdLi++ ) {
1172 - var __wpdLink = __wpdLinks[ __wpdLi ];
1173 - var __wpdKey;
1174 - try {
1175 - var __wpdU = new URL( __wpdLink.href || '', window.location.href );
1176 - __wpdKey = ( __wpdU.pathname.split( '/' ).pop() || '' ) + __wpdU.search;
1177 - } catch ( __wpdE1 ) { continue; }
1178 - if ( ! __wpdKey ) { continue; }
1179 - var __wpdImgWrap = __wpdLink.querySelector( '.wp-menu-image' );
1180 - if ( ! __wpdImgWrap ) { continue; }
1181 -
1182 - /* (a) <img src> nested inside .wp-menu-image */
1183 - var __wpdImg = __wpdImgWrap.querySelector( 'img' );
1184 - if ( __wpdImg && __wpdImg.src ) {
1185 - __wpdHarvest[ __wpdKey ] = __wpdImg.src;
1186 - continue;
1187 - }
1188 -
1189 - /* (b) dashicon class on the wrap div itself */
1190 - var __wpdDash = ( __wpdImgWrap.className || '' ).match( /\bdashicons-[\w-]+\b/ );
1191 - if (
1192 - __wpdDash
1193 - && __wpdDash[ 0 ] !== 'dashicons-before'
1194 - && __wpdDash[ 0 ] !== 'dashicons-admin-generic'
1195 - ) {
1196 - __wpdHarvest[ __wpdKey ] = __wpdDash[ 0 ];
1197 - continue;
1198 - }
1199 -
1200 - /* (c) ::before background-image — pass the raw
1201 - * `url(...)` CSS value through; the parent's
1202 - * resolveIcon can hand it straight to _makeSvgIcon
1203 - * regardless of whether it's base64-encoded SVG,
1204 - * URL-encoded SVG, or a plain http(s) URL. */
1205 - try {
1206 - var __wpdBefore = window.getComputedStyle( __wpdImgWrap, '::before' );
1207 - var __wpdBg = __wpdBefore && __wpdBefore.backgroundImage;
1208 - if ( __wpdBg && __wpdBg !== 'none' && __wpdBg.indexOf( 'url("")' ) === -1 ) {
1209 - __wpdHarvest[ __wpdKey ] = __wpdBg;
1210 - continue;
1211 - }
1212 - /* (d) background on the wrap itself */
1213 - var __wpdWrapBg = window.getComputedStyle( __wpdImgWrap ).backgroundImage;
1214 - if ( __wpdWrapBg && __wpdWrapBg !== 'none' && __wpdWrapBg.indexOf( 'url("")' ) === -1 ) {
1215 - __wpdHarvest[ __wpdKey ] = __wpdWrapBg;
1216 - }
1217 - } catch ( __wpdE2 ) { /* getComputedStyle may throw on detached nodes */ }
1218 - }
1219 -
1220 - var __wpdItems = __OPENSTATION_MENU_PAYLOAD__.dockItems;
1221 - for ( var __wpdDi = 0; __wpdDi < __wpdItems.length; __wpdDi++ ) {
1222 - var __wpdItem = __wpdItems[ __wpdDi ];
1223 - if ( ! __wpdItem || __wpdItem.icon !== 'dashicons-admin-generic' ) { continue; }
1224 - if ( typeof __wpdItem.url !== 'string' || ! __wpdItem.url ) { continue; }
1225 - try {
1226 - var __wpdItemU = new URL( __wpdItem.url, window.location.href );
1227 - var __wpdItemKey = ( __wpdItemU.pathname.split( '/' ).pop() || '' ) + __wpdItemU.search;
1228 - if ( __wpdHarvest[ __wpdItemKey ] ) {
1229 - __wpdItem.icon = __wpdHarvest[ __wpdItemKey ];
1230 - }
1231 - } catch ( __wpdE3 ) { /* malformed url — leave icon alone */ }
1232 - }
1233 - }
1234 - }
1235 - } catch ( __wpdHarvestErr ) {
1236 - /* Harvest is best-effort; on any failure we still ship the
1237 - * server-built payload, which is exactly the pre-fix behavior. */
1238 - }
1239 - /*
1240 - * Menu payload / signature target: the SHELL, i.e. the top window —
1241 - * not the immediate parent. For a normal window iframe the two are
1242 - * the same frame, but the bulk updater nests: update-core.php (the
1243 - * window iframe) hosts a progress iframe of `update.php?action=
1244 - * update-selected`, whose `iframe_footer()` fires `admin_footer`
1245 - * AFTER the upgrades ran — exactly the fresh payload the shell
1246 - * wants. Posting that to `window.parent` hands it to the
1247 - * update-core.php page, which has no listener, and the dock badge
1248 - * stays stale (GH#296). `window.top` reaches the shell from any
1249 - * nesting depth; the targetOrigin pin means a cross-origin top
1250 - * (foreign page iframing wp-admin) simply never receives it.
1251 - */
1252 - try {
1253 - var __wpdShell = window.top || window.parent;
1254 - if ( __OPENSTATION_MENU_PAYLOAD__ ) {
1255 - __wpdShell.postMessage(
1256 - {
1257 - type: 'os-plugins-changed',
1258 - payload: __OPENSTATION_MENU_PAYLOAD__
1259 - },
1260 - window.location.origin
1261 - );
1262 - } else if ( __OPENSTATION_MENU_SIG__ ) {
1263 - /*
1264 - * No full payload on this page — but we still ship the cheap
1265 - * menu signature so the shell can notice a menu change that
1266 - * happened somewhere off the plugins/themes/update path (a
1267 - * CPT registered via a settings tool, a plugin that adds a
1268 - * menu on save, …) and spend a refresh probe only then.
1269 - * GH#325.
1270 - */
1271 - __wpdShell.postMessage(
1272 - {
1273 - type: 'os-menu-signature',
1274 - sig: __OPENSTATION_MENU_SIG__
1275 - },
1276 - window.location.origin
1277 - );
1278 - }
1279 - } catch ( err ) {
1280 - /* postMessage throws only on structured-clone failures, which
1281 - * this static payload won't hit. Swallow defensively so a
1282 - * wayward extension wrapping window.parent can't break the
1283 - * rest of the bridge. */
1284 - }
1285 -
1286 - /*
1287 - * Link & form interceptor.
1288 - *
1289 - * Every same-origin wp-admin <a> href and <form> action gets the
1290 - * `openstation_chromeless=1` flag appended so navigation inside the iframe stays
1291 - * chromeless. Without this, a stray link to /wp-admin/edit.php (see
1292 - * Gutenberg's fullscreen close button, help-tab links, "Return to
1293 - * posts" affordances, etc.) re-renders the full classic admin inside
1294 - * our window.
1295 - *
1296 - * Excluded from rewriting:
1297 - * - modifier clicks (cmd/ctrl/shift/alt) — user wants to open a
1298 - * new tab/window, respect that
1299 - * - target="_blank" / target="_top" / target="_parent"
1300 - * - download attribute
1301 - * - in-page anchors (#)
1302 - * - mailto:, tel:, javascript: schemes
1303 - * - cross-origin URLs
1304 - * - URLs that already carry openstation_chromeless=
1305 - */
1306 - function rewriteAdminUrl( href, base ) {
1307 - if ( ! href || href.charAt( 0 ) === '#' ) {
1308 - return null;
1309 - }
1310 - if ( /^(mailto:|tel:|javascript:|data:)/i.test( href ) ) {
1311 - return null;
1312 - }
1313 - var url;
1314 - try {
1315 - url = new URL( href, base );
1316 - } catch ( err ) {
1317 - return null;
1318 - }
1319 - if ( url.origin !== window.location.origin ) {
1320 - return null;
1321 - }
1322 - if ( url.pathname.indexOf( '/wp-admin/' ) === -1 ) {
1323 - return null;
1324 - }
1325 - if ( url.searchParams.has( 'openstation_chromeless' ) ) {
1326 - return null;
1327 - }
1328 - url.searchParams.set( 'openstation_chromeless', '1' );
1329 - return url.toString();
1330 - }
1331 -
1332 - /*
1333 - * Classify a link so we know whether to rewrite it (admin),
1334 - * escalate it to the parent shell (external / non-admin), or let
1335 - * the browser navigate naturally (mailto, anchor, download, etc.).
1336 - *
1337 - * 'admin' — same-origin /wp-admin/ URL we rewrite in place.
1338 - * 'external' — http(s) URL we want the parent shell to open
1339 - * as a sub-tab instead of navigating the iframe
1340 - * out of wp-admin. Covers both cross-origin
1341 - * links (plugin author sites, external docs) AND
1342 - * same-origin non-admin links (the site's own
1343 - * front-end pages).
1344 - * 'passthrough' — anything else (mailto, tel, javascript, data,
1345 - * anchors, unparseable). The browser handles it.
1346 - */
1347 - function classifyLink( href, base ) {
1348 - if ( ! href || href.charAt( 0 ) === '#' ) {
1349 - return 'passthrough';
1350 - }
1351 - if ( /^(mailto:|tel:|javascript:|data:)/i.test( href ) ) {
1352 - return 'passthrough';
1353 - }
1354 - var url;
1355 - try {
1356 - url = new URL( href, base );
1357 - } catch ( err ) {
1358 - return 'passthrough';
1359 - }
1360 - if ( url.protocol !== 'http:' && url.protocol !== 'https:' ) {
1361 - return 'passthrough';
1362 - }
1363 - if (
1364 - url.origin === window.location.origin &&
1365 - url.pathname.indexOf( '/wp-admin/' ) !== -1
1366 - ) {
1367 - return 'admin';
1368 - }
1369 - return 'external';
1370 - }
1371 -
1372 - /*
1373 - * Rewrite a link's href to carry `_wp_http_referer=<this page>`.
1374 - *
1375 - * The iframe-side twin of `stampSourceReferer()` in
1376 - * `src/window/iframe-bridge.ts`, for links the interceptor yields
1377 - * on: the parent never sees the click, so it can't stamp them.
1378 - * `wp_get_referer()` reads `$_REQUEST['_wp_http_referer']` ahead
1379 - * of the raw header, so the param survives any `Referrer-Policy`.
1380 - *
1381 - * Same three guards as the parent, for the same reasons: never
1382 - * overwrite a referer the markup already supplied, stay
1383 - * same-origin (a mis-attributed referer is worse than none), and
1384 - * strip the chromeless flag from the hint so it doesn't loop into
1385 - * whatever redirect WP builds out of it.
1386 - */
1387 - function stampSourceRefererOnLink( link ) {
1388 - try {
1389 - var target = new URL( link.getAttribute( 'href' ), window.location.href );
1390 - if ( target.origin !== window.location.origin ) {
1391 - return;
1392 - }
1393 - if ( target.searchParams.has( '_wp_http_referer' ) ) {
1394 - return;
1395 - }
1396 - var source = new URL( window.location.href );
1397 - source.searchParams.delete( 'openstation_chromeless' );
1398 - target.searchParams.set(
1399 - '_wp_http_referer',
1400 - source.pathname + ( source.search ? source.search : '' )
1401 - );
1402 - link.setAttribute( 'href', target.toString() );
1403 - } catch ( err ) {
1404 - /* Unparseable href, so leave the link exactly as it was. */
1405 - }
1406 - }
1407 -
1408 - /*
1409 - * The text a link actually SHOWS, for use as a window title.
1410 - *
1411 - * `textContent` is the wrong source on its own. WP Core routinely
1412 - * pairs a terse visible label with a longer screen-reader one
1413 - * inside the same anchor, and reads back as both at once. Drop the
1414 - * screen-reader half (`.screen-reader-text` is Core's own class for
1415 - * it, `.hidden` covers the markup that toggles) and collapse the
1416 - * indentation whitespace the templates leave behind.
1417 - *
1418 - * The parent treats a label harvested this way as provisional and
1419 - * upgrades it to the destination page's own title once the iframe
1420 - * loads — see `titleFromPage` in `src/types.ts`.
1421 - */
1422 - /*
1423 - * The wp-admin filename a URL points at (`revision.php`), or an
1424 - * empty string when it can't be read. Used to tell "a different
1425 - * admin screen" from "another view of this one" without needing
1426 - * the shell's window-slug rules.
1427 - */
1428 - function adminFileOf( url ) {
1429 - try {
1430 - return new URL( url, window.location.href ).pathname.split( '/' ).pop();
1431 - } catch ( err ) {
1432 - return '';
1433 - }
1434 - }
1435 -
1436 - function visibleLinkText( link ) {
1437 - var clone = link.cloneNode( true );
1438 - var muted = clone.querySelectorAll( '.screen-reader-text, .hidden' );
1439 - for ( var i = 0; i < muted.length; i++ ) {
1440 - if ( muted[ i ].parentNode ) {
1441 - muted[ i ].parentNode.removeChild( muted[ i ] );
1442 - }
1443 - }
1444 - return ( clone.textContent || '' ).replace( /\s+/g, ' ' ).trim();
1445 - }
1446 -
1447 - document.addEventListener( 'click', function ( e ) {
1448 - if ( e.defaultPrevented ) {
1449 - return;
1450 - }
1451 - if ( e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey ) {
1452 - return;
1453 - }
1454 - var link = e.target && e.target.closest ? e.target.closest( 'a[href]' ) : null;
1455 - if ( ! link ) {
1456 - return;
1457 - }
1458 - /*
1459 - * A link that names another browsing context.
1460 - *
1461 - * `_blank` on a /wp-admin/ URL means "open this admin screen
1462 - * without losing the one I am on", and inside the shell that
1463 - * is another window rather than a browser tab that drops the
1464 - * user out of the desktop. Claimed only when the destination
1465 - * is a DIFFERENT wp-admin file: whether two URLs on one file
1466 - * are the same "page" depends on the shell's window-slug
1467 - * rules, which the iframe can't see, and guessing wrong makes
1468 - * the parent navigate the window the link was clicked in.
1469 - *
1470 - * Every other target yields. `_top` / `_parent` are a
1471 - * deliberate "replace the whole shell" and a page's only
1472 - * escape hatch; a named target (`wp-preview-4`) reuses one
1473 - * specific tab across clicks, which a window cannot honour;
1474 - * and any target on a non-admin URL has no window to open
1475 - * into.
1476 - */
1477 - var newContext = false;
1478 - var linkTarget = link.target || '';
1479 - if ( linkTarget !== '' && linkTarget !== '_self' ) {
1480 - var claimable =
1481 - linkTarget === '_blank' &&
1482 - classifyLink( link.getAttribute( 'href' ), window.location.href ) === 'admin' &&
1483 - adminFileOf( link.getAttribute( 'href' ) ) !== '' &&
1484 - adminFileOf( link.getAttribute( 'href' ) ) !== adminFileOf( window.location.href );
1485 - if ( ! claimable ) {
1486 - return;
1487 - }
1488 - newContext = true;
1489 - }
1490 - if ( link.hasAttribute( 'download' ) ) {
1491 - return;
1492 - }
1493 - /*
1494 - * Activity-footprint launcher. A "View activity footprint" row
1495 - * action (added to the Users list table by
1496 - * `openstation_user_footprint_row_action`) carries the target
1497 - * user id in `data-os-footprint`. The iframe has no
1498 - * shell API of its own, so we escalate the click to the parent
1499 - * shell, which opens the My WordPress window on that user's
1500 - * footprint. Checked BEFORE classifyLink so the link's real
1501 - * href — a graceful profile-edit fallback for no-JS — is never
1502 - * followed inside the shell. Modifier-key / middle clicks are
1503 - * already filtered above, so cmd/ctrl-click still opens that
1504 - * fallback in a new browser tab.
1505 - */
1506 - var footprintAttr = link.getAttribute( 'data-os-footprint' );
1507 - if ( footprintAttr ) {
1508 - var footprintUid = parseInt( footprintAttr, 10 );
1509 - if ( footprintUid > 0 ) {
1510 - e.preventDefault();
1511 - try {
1512 - window.parent.postMessage(
1513 - {
1514 - type: 'os-open-user-footprint',
1515 - userId: footprintUid,
1516 - userName: link.getAttribute( 'data-os-footprint-name' ) || ''
1517 - },
1518 - window.location.origin
1519 - );
1520 - } catch ( footprintErr ) {
1521 - /* Same-origin postMessage can only fail in a sandbox
1522 - * we don't support — swallow rather than block the
1523 - * click. */
1524 - }
1525 - return;
1526 - }
1527 - }
1528 - /*
1529 - * `aria-button-if-js` is WP core's own marker for "this anchor
1530 - * is really an in-page button; the href is only the no-JS
1531 - * fallback". Core stamps `role="button"` on every one of them
1532 - * (`wp-admin/js/common.js`), and the owning script binds a
1533 - * bubble-phase handler that calls preventDefault: media-grid.js
1534 - * for the Media Library's uploader toggle, wp-lists for the
1535 - * comment row actions, tags.js for term Delete, updates.js
1536 - * for the auto-update toggles.
1537 - *
1538 - * Our capture-phase handler runs first, so hijacking these
1539 - * substitutes the fallback URL for the in-page action the user
1540 - * actually asked for. On the Media Library grid that showed up as
1541 - * two uploaders at once: the shell opened a window for
1542 - * `media-new.php` (the fallback) while media-grid.js's
1543 - * `addNewClickHandler` still expanded the inline drop zone in the
1544 - * Media window behind it, and closing the window left the drop
1545 - * zone stranded above the grid.
1546 - *
1547 - * The class does NOT promise a handler, though. The Media list
1548 - * table stamps it on Trash / Restore / Delete Permanently
1549 - * (`.submitdelete`, `class-wp-media-list-table.php`) and binds
1550 - * nothing: the href really is the navigation. Yielding is still
1551 - * right for those (the inline `onclick` confirm runs, and
1552 - * cancelling actually cancels, which it did not when we
1553 - * preventDefaulted in capture ahead of it), but the parent's
1554 - * destructive-action path used to stamp `_wp_http_referer` on
1555 - * them, and a raw navigation loses that. See `stampSourceReferer`
1556 - * in `src/window/iframe-bridge.ts` for the full rationale; the
1557 - * short version is that a `Referrer-Policy` of `strict-origin` or
1558 - * tighter downgrades the `Referer` header to the bare origin,
1559 - * which `post.php` matches against neither `post.php` nor
1560 - * `post-new.php`, so `$sendback` stays the origin and the window
1561 - * lands on the site front page instead of back on the list.
1562 - *
1563 - * So stamp the hint ourselves before yielding. In here the
1564 - * source page IS `window.location`, which makes this the same
1565 - * value the parent would have computed, minus the round trip.
1566 - */
1567 - if ( link.classList.contains( 'aria-button-if-js' ) ) {
1568 - if ( link.classList.contains( 'submitdelete' ) ) {
1569 - stampSourceRefererOnLink( link );
1570 - }
1571 - return;
1572 - }
1573 - /*
1574 - * Same story as `aria-button-if-js` above, minus the marker
1575 - * class: `plugin-install.php`'s "Upload Plugin" href is a
1576 - * no-JS fallback, and plugin-install.js binds a bubble-phase
1577 - * handler that opens the drop zone in place above the plugin
1578 - * cards. Our capture handler used to win and navigate to
1579 - * `?tab=upload`, which shows the uploader with no cards.
1580 - *
1581 - * On that page core skips the binding on purpose ("let the
1582 - * link behave like a link"), flagged by
1583 - * `plugin-install-tab-upload` on the wrap. There the href is
1584 - * the real navigation, so we route it as usual.
1585 - *
1586 - * `theme-install.php`'s Upload Theme is a `<button>`, so it
1587 - * never reaches this handler.
1588 - */
1589 - if ( link.classList.contains( 'upload-view-toggle' ) ) {
1590 - var uploadWrap = link.closest( '.wrap' );
1591 - if (
1592 - ! uploadWrap ||
1593 - ! uploadWrap.classList.contains( 'plugin-install-tab-upload' )
1594 - ) {
1595 - return;
1596 - }
1597 - }
1598 - /*
1599 - * Same shape as the toggles above: dashboard.js binds the
1600 - * dismiss on the anchor and preventDefaults, so our capture
1601 - * handler gets there first and routes `?welcome=0` (a dead
1602 - * no-JS fallback) into a second Dashboard window titled
1603 - * "Dismiss". Scoped like core's own selector, so a
1604 - * `welcome-panel-close` elsewhere still routes.
1605 - */
1606 - if (
1607 - link.closest( '#welcome-panel' ) &&
1608 - ( link.classList.contains( 'welcome-panel-close' ) ||
1609 - link.closest( '.welcome-panel-dismiss' ) )
1610 - ) {
1611 - return;
1612 - }
1613 - /*
1614 - * WordPress core's wp-admin/js/updates.js owns the click on these
1615 - * AJAX-driven plugin/theme management buttons — it binds in bubble
1616 - * phase and calls preventDefault to take over with an in-place
1617 - * AJAX install / update / delete (with its own progress spinner
1618 - * and inline success/failure UX). Our capture-phase handler would
1619 - * preempt it: preventDefault here fires BEFORE updates.js's own,
1620 - * the AJAX call never starts, and the postMessage below diverts
1621 - * the user to the link's no-JS fallback URL (update.php?action=
1622 - * install-plugin&...) opened as a freshly spawned desktop window.
1623 - * That fallback technically completes the install server-side,
1624 - * but it's a long blocking page-load with no in-place feedback —
1625 - * which is what users perceive as "Install Now keeps loading and
1626 - * opens a new tab". Skip these classes so updates.js's bubble
1627 - * handler runs as core intended.
1628 - *
1629 - * The plugins-list-table row action "Delete" is the same story
1630 - * with a different marker: a bare `a.delete` inside a
1631 - * `tr[data-plugin]` (updates.js binds `[data-plugin] a.delete`;
1632 - * the network themes list is `.themes-php.network-admin
1633 - * a.delete`) — it never carries the `delete-plugin` /
1634 - * `delete-theme` classes of the card-style buttons above.
1635 - * Hijacking it navigated the iframe to the link's no-JS
1636 - * bulk-delete fallback WHILE updates.js's AJAX delete was
1637 - * already running: `wp.updates.beforeunload` raised a native
1638 - * "Leave site?" prompt, and leaving landed on a delete
1639 - * confirmation screen for a plugin whose files the AJAX call
1640 - * had just removed — an empty "You are about to remove:" list.
1641 - */
1642 - if (
1643 - link.classList.contains( 'install-now' ) ||
1644 - link.classList.contains( 'update-link' ) ||
1645 - link.classList.contains( 'update-now' ) ||
1646 - link.classList.contains( 'delete-plugin' ) ||
1647 - link.classList.contains( 'delete-theme' ) ||
1648 - link.classList.contains( 'install-theme' ) ||
1649 - ( link.classList.contains( 'delete' ) &&
1650 - ( link.closest( '[data-plugin]' ) ||
1651 - ( document.body.classList.contains( 'themes-php' ) &&
1652 - document.body.classList.contains( 'network-admin' ) ) ) )
1653 - ) {
1654 - return;
1655 - }
1656 - var href = link.getAttribute( 'href' );
1657 - var kind = classifyLink( href, window.location.href );
1658 - if ( kind === 'admin' ) {
1659 - var rewritten = rewriteAdminUrl( href, window.location.href );
1660 - if ( rewritten ) {
1661 - link.setAttribute( 'href', rewritten );
1662 - }
1663 - /*
1664 - * Hand admin-internal navigation to the parent shell.
1665 - *
1666 - * The parent decides what to do with each click:
1667 - *
1668 - * - Native-window remap hits (e.g. `edit.php` while the
1669 - * user has the native Posts opt-in on) → parent opens
1670 - * the native window and closes THIS iframe.
1671 - * - Same-page nav (pagination, filtering on the same
1672 - * `edit.php?post_type=page` screen, etc.) → parent
1673 - * drives the iframe's `location.assign()` so the
1674 - * in-place navigation matches the user's intent.
1675 - * - Cross-page nav (e.g. clicking "Posts" from inside
1676 - * the Pages window) → parent opens a new window for
1677 - * the destination and leaves THIS iframe untouched,
1678 - * so the user keeps both contexts.
1679 - *
1680 - * We `preventDefault()` so the iframe never starts a
1681 - * navigation the parent might want to suppress; otherwise
1682 - * cross-page clicks would trash the source window before
1683 - * the parent had a chance to react. Modifier-key clicks
1684 - * (cmd/ctrl/shift/alt, middle-click) are already filtered
1685 - * upstream so the browser's native "open in new tab" path
1686 - * still works.
1687 - */
1688 - e.preventDefault();
1689 - try {
1690 - var absolute = new URL( rewritten || href, window.location.href ).toString();
1691 - /*
1692 - * Ship the link's visible text along with the URL so
1693 - * the parent can title a freshly-opened window with
1694 - * something the user recognises ("Scheduler") instead
1695 - * of the URL slug ("tools-php-page-scheduler") when
1696 - * the destination has no dock tile to copy a title
1697 - * from. The iframe itself never auto-emits a
1698 - * title-change, so without this hint the slug-as-
1699 - * title fallback would persist for the lifetime of
1700 - * the new window.
1701 - */
1702 - var adminLabel = visibleLinkText( link ) ||
1703 - link.getAttribute( 'title' ) ||
1704 - link.getAttribute( 'aria-label' ) ||
1705 - '';
1706 - window.parent.postMessage(
1707 - {
1708 - type: 'os-iframe-admin-link',
1709 - url: absolute,
1710 - label: adminLabel.slice( 0, 80 ),
1711 - /*
1712 - * The link asked for a new browsing context, so
1713 - * the parent must give the destination its own
1714 - * window rather than driving this one. Without
1715 - * the flag it would still be free to pick an
1716 - * in-place branch — the destructive-action one
1717 - * fires on slug mismatch, which is exactly the
1718 - * shape a `_blank` reaches us with.
1719 - */
1720 - newContext: newContext
1721 - },
1722 - window.location.origin
1723 - );
1724 - } catch ( bridgeErr ) {
1725 - /* Same-origin postMessage to the same window can only fail in
1726 - * a sandbox we don't support — swallow rather than block the
1727 - * click. */
1728 - }
1729 - return;
1730 - }
1731 - if ( kind === 'external' ) {
1732 - /*
1733 - * External navigation inside an admin iframe would leave
1734 - * the user stranded in a chrome-free version of whatever
1735 - * site the link points at. Escalate to the parent shell
1736 - * so it opens the URL as a closeable sub-tab (with a
1737 - * detach button) alongside the admin tab — the user
1738 - * stays inside the desktop shell.
1739 - *
1740 - * Resolving the href against the document base gives the
1741 - * parent an absolute URL it doesn't have to re-resolve.
1742 - */
1743 - e.preventDefault();
1744 - var absolute;
1745 - try {
1746 - absolute = new URL( href, window.location.href ).toString();
1747 - } catch ( err ) {
1748 - return;
1749 - }
1750 - var label = visibleLinkText( link ) ||
1751 - link.getAttribute( 'title' ) ||
1752 - absolute;
1753 - window.parent.postMessage(
1754 - {
1755 - type: 'os-external-link',
1756 - url: absolute,
1757 - label: label.slice( 0, 80 )
1758 - },
1759 - window.location.origin
1760 - );
1761 - }
1762 - }, true );
1763 -
1764 - document.addEventListener( 'submit', function ( e ) {
1765 - var form = e.target;
1766 - if ( ! form || form.tagName !== 'FORM' ) {
1767 - return;
1768 - }
1769 - var action = form.getAttribute( 'action' );
1770 - var rewritten = rewriteAdminUrl( action || window.location.href, window.location.href );
1771 - if ( rewritten ) {
1772 - form.setAttribute( 'action', rewritten );
1773 - }
1774 - }, true );
1775 -
1776 - /*
1777 - * Focus-request bridge.
1778 - *
1779 - * Clicks inside an iframe don't cross the browsing-context
1780 - * boundary — the parent shell's pointerdown / focusin listeners
1781 - * never see them, so without this hook the only way to focus an
1782 - * iframe window would be clicking its title bar chrome. Post a
1783 - * `os-focus-request` message on every pointerdown; the
1784 - * parent Window class treats it as an onFocusRequest. Capture
1785 - * phase so the signal fires before any stopPropagation inside
1786 - * a page's own handlers.
1787 - */
1788 - function postFocusRequest() {
1789 - try {
1790 - window.parent.postMessage(
1791 - { type: 'os-focus-request' },
1792 - window.location.origin
1793 - );
1794 - } catch ( err ) {
1795 - /* cross-origin parent (shouldn't happen for chromeless
1796 - * pages, but don't let a throw break the bridge) */
1797 - }
1798 - }
1799 -
1800 - document.addEventListener( 'pointerdown', postFocusRequest, true );
1801 -
1802 - /*
1803 - * Nested-frame focus escalation.
1804 - *
1805 - * The document-level listener above never hears clicks inside
1806 - * NESTED iframes: Gutenberg renders the post canvas in one
1807 - * (`editor-canvas`, srcdoc → same-origin), and TinyMCE's visual
1808 - * mode uses `#content_ifr`. Without this hook, clicking into the
1809 - * canvas of an unfocused editor window is swallowed — only the
1810 - * toolbar/sidebar (outer document) would focus the window.
1811 - * Attach the same escalation inside every same-origin nested
1812 - * frame: on load (each navigation creates a fresh document) and
1813 - * as frames mount (Gutenberg creates the canvas asynchronously
1814 - * and re-creates it, e.g. on device-preview switches).
1815 - *
1816 - * The observer walks only each record's `addedNodes` — the same
1817 - * shape as the component-sniffer observer at the top of this
1818 - * file, and for the same reason. Re-querying the whole document
1819 - * per mutation batch would put an O(DOM) tree walk on Gutenberg's
1820 - * typing path, which is precisely when the editor mutates hardest
1821 - * (and precisely when the editor-preview pairing is live). A
1822 - * frame that was never inserted cannot need hooking, so the
1823 - * narrow sweep loses nothing. The WeakSets keep it idempotent
1824 - * when a subtree is moved rather than created.
1825 - */
1826 - var hookedFrameDocs = new WeakSet();
1827 - var hookedFrameEls = new WeakSet();
1828 -
1829 - function hookNestedFrameDoc( frame ) {
1830 - var doc;
1831 - try {
1832 - doc = frame.contentDocument;
1833 - } catch ( err ) {
1834 - return; /* cross-origin frame — unreachable, skip */
1835 - }
1836 - if ( ! doc || hookedFrameDocs.has( doc ) ) {
1837 - return;
1838 - }
1839 - hookedFrameDocs.add( doc );
1840 - doc.addEventListener( 'pointerdown', postFocusRequest, true );
1841 - }
1842 -
1843 - function hookNestedFrame( frame ) {
1844 - if ( ! hookedFrameEls.has( frame ) ) {
1845 - hookedFrameEls.add( frame );
1846 - frame.addEventListener( 'load', function ( ev ) {
1847 - hookNestedFrameDoc( ev.target );
1848 - } );
1849 - }
1850 - hookNestedFrameDoc( frame );
1851 - }
1852 -
1853 - /*
1854 - * Hook every iframe at or below `root`. `root` is the document on
1855 - * the initial sweep and a freshly-added node thereafter, so the
1856 - * walk stays proportional to what actually changed.
1857 - */
1858 - function hookNestedFrames( root ) {
1859 - if ( ! root || ( 1 !== root.nodeType && 9 !== root.nodeType ) ) {
1860 - return; /* text / comment node — nothing to walk */
1861 - }
1862 - if ( 'IFRAME' === root.nodeName ) {
1863 - hookNestedFrame( root );
1864 - }
1865 - var frames = root.querySelectorAll( 'iframe' );
1866 - for ( var i = 0; i < frames.length; i++ ) {
1867 - hookNestedFrame( frames[ i ] );
1868 - }
1869 - }
1870 -
1871 - hookNestedFrames( document );
1872 - if ( window.MutationObserver ) {
1873 - new MutationObserver( function ( records ) {
1874 - for ( var r = 0; r < records.length; r++ ) {
1875 - var added = records[ r ].addedNodes;
1876 - for ( var n = 0; n < added.length; n++ ) {
1877 - hookNestedFrames( added[ n ] );
1878 - }
1879 - }
1880 - } ).observe(
1881 - document.documentElement,
1882 - { childList: true, subtree: true }
1883 - );
1884 - }
1885 -
1886 - /*
1887 - * OS-file drop forwarder. When the user drags a file from the
1888 - * host OS into a chromeless admin iframe, intercept the drop
1889 - * before the browser's default "navigate the iframe to the
1890 - * file" handler fires, and `postMessage` the raw `File[]` up
1891 - * to the parent shell so the OS-file drop manager
1892 - * (`src/os-file-drop/manager.ts`) can show the upload dialog.
1893 - *
1894 - * Same-origin postMessage preserves `File` identity — the
1895 - * parent receives real `File` objects, no base64 round-trip.
1896 - *
1897 - * We only intercept drops whose `DataTransfer.types` includes
1898 - * `'Files'`. In-page DnD (Gutenberg block reorders, media
1899 - * library drags) carries non-`Files` types and passes through
1900 - * untouched.
1901 - */
1902 - function bridgeHasFiles( ev ) {
1903 - var t = ev && ev.dataTransfer && ev.dataTransfer.types;
1904 - if ( ! t ) {
1905 - return false;
1906 - }
1907 - if ( typeof t.includes === 'function' ) {
1908 - return t.includes( 'Files' );
1909 - }
1910 - if ( typeof t.contains === 'function' ) {
1911 - return t.contains( 'Files' );
1912 - }
1913 - for ( var i = 0; i < t.length; i++ ) {
1914 - if ( t[ i ] === 'Files' ) {
1915 - return true;
1916 - }
1917 - }
1918 - return false;
1919 - }
1920 - /*
1921 - * Selectors of in-iframe drop receivers we leave alone —
1922 - * Gutenberg's drop zone, the legacy media uploader, any
1923 - * element a plugin marks with `data-drop-zone`. The whole
1924 - * point: file drops onto Gutenberg blocks keep firing
1925 - * Gutenberg's handler; only drops on the empty page
1926 - * background escalate to the shell.
1927 - */
1928 - var bridgeDropPassthroughSelectors = [
1929 - '.components-drop-zone',
1930 - '[data-drop-zone]',
1931 - '.uploader-window',
1932 - '.media-frame-content'
1933 - ];
1934 - function bridgeDropTargetWantsFile( target ) {
1935 - if ( ! target || ! target.closest ) {
1936 - return false;
1937 - }
1938 - for ( var s = 0; s < bridgeDropPassthroughSelectors.length; s++ ) {
1939 - if ( target.closest( bridgeDropPassthroughSelectors[ s ] ) ) {
1940 - return true;
1941 - }
1942 - }
1943 - return false;
1944 - }
1945 - /*
1946 - * Bubble phase (not capture): the inner-most handler — Gutenberg's
1947 - * drop zone, the legacy media uploader, or a third-party plugin
1948 - * like "Administrador de archivos WP" — runs FIRST and gets the
1949 - * chance to call `preventDefault()` to claim the drop. Our
1950 - * forwarder then runs LAST at the document level and yields to
1951 - * anyone who already took ownership.
1952 - *
1953 - * Two bail conditions, in order:
1954 - * 1. `bridgeDropTargetWantsFile()` — the curated allowlist
1955 - * (Gutenberg, wp.media, anything tagged `[data-drop-zone]`).
1956 - * Kept as the primary check so the well-known core surfaces
1957 - * behave identically to before, even if some edge case skips
1958 - * the `preventDefault()` step.
1959 - * 2. `ev.defaultPrevented` — the universal HTML5 contract: any
1960 - * drop zone willing to receive a file calls `preventDefault()`
1961 - * on `dragover` (mandatory per spec) and `drop` (to suppress
1962 - * the browser's default navigate-to-file). When that's true,
1963 - * some inner handler has taken the drop — yield so plugins
1964 - * outside the allowlist (WP File Manager, Yoast, etc.) keep
1965 - * their native UX.
1966 - */
1967 - document.addEventListener( 'dragover', function ( ev ) {
1968 - if ( ! bridgeHasFiles( ev ) ) {
1969 - return;
1970 - }
1971 - if ( bridgeDropTargetWantsFile( ev.target ) ) {
1972 - return;
1973 - }
1974 - if ( ev.defaultPrevented ) {
1975 - return;
1976 - }
1977 - ev.preventDefault();
1978 - if ( ev.dataTransfer ) {
1979 - ev.dataTransfer.dropEffect = 'copy';
1980 - }
1981 - }, false );
1982 - document.addEventListener( 'drop', function ( ev ) {
1983 - if ( ! bridgeHasFiles( ev ) ) {
1984 - return;
1985 - }
1986 - if ( bridgeDropTargetWantsFile( ev.target ) ) {
1987 - return;
1988 - }
1989 - if ( ev.defaultPrevented ) {
1990 - return;
1991 - }
1992 - ev.preventDefault();
1993 - ev.stopPropagation();
1994 - var files = [];
1995 - if ( ev.dataTransfer && ev.dataTransfer.files ) {
1996 - for ( var i = 0; i < ev.dataTransfer.files.length; i++ ) {
1997 - files.push( ev.dataTransfer.files[ i ] );
1998 - }
1999 - }
2000 - if ( files.length === 0 ) {
2001 - return;
2002 - }
2003 - try {
2004 - window.parent.postMessage(
2005 - {
2006 - type: 'os-file-drop',
2007 - files: files,
2008 - x: ev.clientX,
2009 - y: ev.clientY,
2010 - },
2011 - window.location.origin
2012 - );
2013 - } catch ( err ) { /* cross-origin parent; swallow */ }
2014 - }, false );
2015 -
2016 - /*
2017 - * Drag-hover forwarder. Native drag events don't cross iframe
2018 - * boundaries, so when the user holds ANY drag (an OS file, an
2019 - * image lifted off another admin page, a text selection) over
2020 - * this window, the parent shell has no idea the window is being
2021 - * hovered. Forward a throttled, payload-free heartbeat so the
2022 - * shell's focus-on-drag-hover module
2023 - * (`src/drag/focus-window-on-drag-hover.ts`) can raise this
2024 - * window after its dwell. Purely observational — no
2025 - * `preventDefault()`, no interference with in-page drop zones.
2026 - * The parent identifies the hovered window from the message
2027 - * source, so no coordinates travel.
2028 - *
2029 - * Sentinel-guarded: the standalone bridge bundle
2030 - * (`iframe-bridge-standalone.ts`) installs the same forwarder,
2031 - * and unlike the drop forwarder above there is no
2032 - * `defaultPrevented` handshake to dedupe a double install.
2033 - */
2034 - if ( ! window.__openStationDragHoverForwarderInstalled ) {
2035 - window.__openStationDragHoverForwarderInstalled = true;
2036 - var dragHoverLastSent = 0;
2037 - document.addEventListener( 'dragover', function ( ev ) {
2038 - var now = Date.now();
2039 - if ( now - dragHoverLastSent < 150 ) {
2040 - return;
2041 - }
2042 - dragHoverLastSent = now;
2043 - try {
2044 - window.parent.postMessage(
2045 - {
2046 - type: 'os-drag-hover',
2047 - payloadType: bridgeHasFiles( ev ) ? 'os-file' : 'external',
2048 - },
2049 - window.location.origin
2050 - );
2051 - } catch ( err ) { /* cross-origin parent; swallow */ }
2052 - }, true );
2053 - }
2054 -
2055 - /*
2056 - * Pointer forwarder — OPT-IN, off by default.
2057 - *
2058 - * Pointer events don't cross iframe boundaries, so the parent
2059 - * shell goes blind to the cursor the moment it enters a window.
2060 - * Anything in the shell that needs to know where the mouse
2061 - * actually is while it's over window content — today, the
2062 - * Mio's gaze (`src/mio/pointer.ts`) — gets a throttled
2063 - * stream of this frame's client coordinates and rebases them
2064 - * through the iframe element's own rect.
2065 - *
2066 - * Strictly opt-in: the parent posts
2067 - * `os-pointer-track { enabled: true }` when a consumer
2068 - * starts, and `{ enabled: false }` when the last one stops. A
2069 - * shell with no consumer never turns this on and pays nothing.
2070 - * `os-bridge-ready` (emitted at the end of this script,
2071 - * i.e. on every navigation) is the parent's cue to re-arm a
2072 - * freshly-loaded frame.
2073 - *
2074 - * Coordinates only — no target element, no event object, nothing
2075 - * about the page content. Purely observational: passive listener,
2076 - * no `preventDefault()`.
2077 - *
2078 - * Sentinel-guarded: the standalone bridge bundle
2079 - * (`iframe-bridge-standalone.ts`) installs the same forwarder.
2080 - */
2081 - if ( ! window.__openStationPointerForwarderInstalled ) {
2082 - window.__openStationPointerForwarderInstalled = true;
2083 - var pointerTrackOn = false;
2084 - var pointerLastSent = 0;
2085 - window.addEventListener( 'message', function ( e ) {
2086 - if ( e.origin !== window.location.origin ) return;
2087 - if ( ! e.data || e.data.type !== 'os-pointer-track' ) return;
2088 - pointerTrackOn = !! e.data.enabled;
2089 - } );
2090 - document.addEventListener( 'pointermove', function ( ev ) {
2091 - if ( ! pointerTrackOn ) return;
2092 - var now = Date.now();
2093 - // ~25 Hz. The consumer interpolates; a faster stream buys
2094 - // nothing visible and costs a postMessage per mouse move.
2095 - if ( now - pointerLastSent < 40 ) return;
2096 - pointerLastSent = now;
2097 - try {
2098 - window.parent.postMessage(
2099 - {
2100 - type: 'os-pointer-move',
2101 - x: ev.clientX,
2102 - y: ev.clientY
2103 - },
2104 - window.location.origin
2105 - );
2106 - } catch ( err ) { /* cross-origin parent; swallow */ }
2107 - }, { capture: true, passive: true } );
2108 - }
2109 -
2110 - /*
2111 - * Cmd+K / Ctrl+K forwarder — single-press, unconditional.
2112 - *
2113 - * Native keydown events don't cross iframe boundaries. Inside a
2114 - * chromeless admin page we want exactly ONE command palette: the
2115 - * desktop shell's. WordPress's own `core/commands` palette is
2116 - * harvested by `__wpdHarvestCommands` below and re-surfaced in the
2117 - * shell palette, so there's no reason to ever let the in-page palette
2118 - * take the keystroke.
2119 - *
2120 - * Capture phase + `stopImmediatePropagation` so we win the race
2121 - * against Gutenberg / TinyMCE / plugin handlers bound to the same
2122 - * shortcut. Shift/Alt modifiers pass through so user shortcuts using
2123 - * those combos keep working.
2124 - */
2125 - document.addEventListener( 'keydown', function ( e ) {
2126 - if ( ! ( e.metaKey || e.ctrlKey ) ) return;
2127 - if ( e.key !== 'k' && e.key !== 'K' ) return;
2128 - if ( e.shiftKey || e.altKey ) return;
2129 -
2130 - e.preventDefault();
2131 - e.stopImmediatePropagation();
2132 -
2133 - try {
2134 - window.parent.postMessage(
2135 - { type: 'os-palette-cycle' },
2136 - window.location.origin
2137 - );
2138 - } catch ( err ) { /* cross-origin parent; swallow */ }
2139 - }, true );
2140 -
2141 - /*
2142 - * Command harvester — bridges `wp.data.select('core/commands')` to
2143 - * the parent shell.
2144 - *
2145 - * On `os-commands-subscribe` from the parent, subscribe to
2146 - * the `core/commands` store and post `os-commands-list` on
2147 - * every change (de-duplicated). On `os-commands-invoke`, run
2148 - * the original callback inside this iframe — the parent fires this
2149 - * when the user selects a proxied command from the shell palette.
2150 - *
2151 - * Commands are classified by dry-invoking their callback inside a
2152 - * `window.location`-intercept sandbox: pure-navigation callbacks
2153 - * are flagged `navigate` (with the captured URL) so the parent can
2154 - * open a new desktop window instead of navigating this iframe out
2155 - * of chromeless mode. Everything else is `action` and proxies back
2156 - * into this iframe on user selection.
2157 - */
2158 - var __wpdCommandsSubscribed = false;
2159 - var __wpdCommandsLastPayload = '';
2160 - var __wpdCommandsDebounceId = null;
2161 - var __wpdCommandsOrigin = window.location.origin;
2162 - // Cache per command name so the `window.location`-intercept
2163 - // sandbox only runs once per command. Re-classifying on every
2164 - // store tick would repeatedly fire side-effectful action
2165 - // callbacks (preference toggles, modal opens) — unacceptable.
2166 - // Keyed by name; value is the frozen classification minus the
2167 - // live `label` / `icon` (which we always re-read in case the
2168 - // command updated its own metadata).
2169 - var __wpdCommandsKindCache = Object.create( null );
2170 -
2171 - function __wpdRenderIconElement( icon ) {
2172 - if ( ! icon ) return '';
2173 - if ( typeof icon === 'string' ) return '';
2174 - if ( ! window.wp || ! window.wp.element || typeof window.wp.element.renderToString !== 'function' ) {
2175 - return '';
2176 - }
2177 - try {
2178 - var rendered = window.wp.element.renderToString( icon );
2179 - // `@wordpress/icons` entries render as a complete `<svg>`
2180 - // tag. Anything else (wrapped components, empty fragments,
2181 - // strings) falls back to dashicons in the palette — we only
2182 - // accept markup we can inject straight into the icon slot.
2183 - if ( typeof rendered === 'string' && rendered.toLowerCase().indexOf( '<svg' ) === 0 ) {
2184 - return rendered;
2185 - }
2186 - } catch ( _err ) { /* swallow */ }
2187 - return '';
2188 - }
2189 -
2190 - function __wpdClassifyCommand( cmd ) {
2191 - // Defensive defaults — a broken registry should not tank the bridge.
2192 - var out = {
2193 - name: String( cmd && cmd.name ? cmd.name : '' ),
2194 - label: String( cmd && cmd.label ? cmd.label : '' ),
2195 - icon: cmd && cmd.icon && typeof cmd.icon === 'string' ? cmd.icon : undefined,
2196 - iconSvg: undefined,
2197 - context: cmd && cmd.context ? String( cmd.context ) : undefined,
2198 - kind: 'action',
2199 - url: undefined
2200 - };
2201 - if ( ! cmd || typeof cmd.callback !== 'function' ) {
2202 - return out;
2203 - }
2204 -
2205 - // Short-circuit on cached classifications — `renderToString` on
2206 - // the React icon is expensive, and the static URL regex scan
2207 - // on `callback.toString()` is pure CPU we've already paid once.
2208 - var cached = __wpdCommandsKindCache[ out.name ];
2209 - if ( cached ) {
2210 - out.kind = cached.kind;
2211 - out.url = cached.url;
2212 - out.iconSvg = cached.iconSvg;
2213 - return out;
2214 - }
2215 -
2216 - // Render the React icon once per command name — Gutenberg
2217 - // commands ship `icon` as a `@wordpress/icons` React element
2218 - // the postMessage bridge can't serialize, so we flatten it to
2219 - // a static SVG string here.
2220 - if ( cmd.icon && typeof cmd.icon !== 'string' ) {
2221 - out.iconSvg = __wpdRenderIconElement( cmd.icon );
2222 - }
2223 -
2224 - // STATIC classification — read the callback's source text and
2225 - // look for a string-literal navigation target. We deliberately
2226 - // do NOT execute the callback. An earlier iteration tried a
2227 - // dry-run with a `window.location` intercept sandbox, but
2228 - // `Location.prototype.href` is non-configurable: the shim
2229 - // silently failed, every nav callback actually navigated the
2230 - // iframe, the new page re-harvested, and the cascade opened
2231 - // windows forever.
2232 - //
2233 - // Cases caught (WP's @wordpress/core-commands callbacks are
2234 - // all of this shape):
2235 - // document.location.href = 'url'
2236 - // window.location.href = "url"
2237 - // location.href = `url`
2238 - // location.assign( 'url' )
2239 - // location.replace( 'url' )
2240 - //
2241 - // Computed URLs (template-literal interpolation, addQueryArgs
2242 - // calls, variables) fall back to `action` — the user picking
2243 - // them will still run the real callback inside the iframe,
2244 - // which is the safe default.
2245 - var src = '';
2246 - try { src = Function.prototype.toString.call( cmd.callback ); } catch ( _err ) { src = ''; }
2247 - var navRe = /(?:document\.location\.href|window\.location\.href|location\.href)\s*=\s*['"]([^'"$]+?)['"]/;
2248 - var asgRe = /location\.(?:assign|replace)\s*\(\s*['"]([^'"$]+?)['"]\s*\)/;
2249 - var mm = src.match( navRe ) || src.match( asgRe );
2250 - if ( mm && mm[ 1 ] ) {
2251 - try {
2252 - out.url = new URL( mm[ 1 ], window.location.href ).toString();
2253 - out.kind = 'navigate';
2254 - } catch ( _err ) {
2255 - out.kind = 'action';
2256 - }
2257 - }
2258 - __wpdCommandsKindCache[ out.name ] = { kind: out.kind, url: out.url, iconSvg: out.iconSvg };
2259 - return out;
2260 - }
2261 -
2262 - // Harvested commands accumulate here. The React harvester writes
2263 - // the full list each render; `__wpdPostCommandsList` reads + posts.
2264 - var __wpdLastRawCommands = [];
2265 - // Name → live `callback` reference. Loader-returned commands are
2266 - // NOT in `wp.data.select('core/commands').getCommands()` — the
2267 - // store only exposes statically-registered entries. Without a
2268 - // private cache keyed off the React harvester's most recent render,
2269 - // invoking a loader command from the parent palette ("Duplicate
2270 - // block", "Transform to...", pattern commands) would silently fall
2271 - // through to the `getCommands()` lookup and no-op.
2272 - var __wpdCommandCallbacks = Object.create( null );
2273 -
2274 - function __wpdFinalizeCommands( raw ) {
2275 - var seen = Object.create( null );
2276 - var out = [];
2277 - var skipped = { missing: 0, disabled: 0, dup: 0 };
2278 - for ( var i = 0; i < raw.length; i++ ) {
2279 - var cmd = raw[ i ];
2280 - if ( ! cmd || ! cmd.name || ! cmd.label ) { skipped.missing++; continue; }
2281 - if ( cmd.disabled ) { skipped.disabled++; continue; }
2282 - if ( seen[ cmd.name ] ) { skipped.dup++; continue; }
2283 - seen[ cmd.name ] = true;
2284 - out.push( __wpdClassifyCommand( cmd ) );
2285 - }
2286 - return out;
2287 - }
2288 -
2289 - function __wpdHarvestCommands() {
2290 - return __wpdFinalizeCommands( __wpdLastRawCommands );
2291 - }
2292 -
2293 - // React-mounted harvester. Block-level / editor-contextual commands
2294 - // (tier 3 loaders like `core/block-editor/selected-block-commands`,
2295 - // `core/edit-post/pattern-commands`) are React *hooks* — they call
2296 - // `useSelect` internally, which only works inside a function-
2297 - // component render. So we mount an invisible React tree whose
2298 - // children invoke each loader's hook at render time. On every
2299 - // re-render (block selection changes, entity edits, welcome guide
2300 - // toggled) the effect re-posts the fresh command list to the
2301 - // parent. One component per loader keeps the rules-of-hooks
2302 - // contract — the hook count inside each `LoaderSlot` is fixed at
2303 - // one call (plus the constant `useEffect`), so React's reconciler
2304 - // is happy.
2305 - var __wpdReactMounted = false;
2306 - // Stashed so `__wpdUnsubscribeCommands` can tear the harvester
2307 - // down when focus leaves the window — otherwise the component
2308 - // keeps re-rendering on every store tick, calling `mergeAndPost`,
2309 - // and posting command lists the parent drops on the floor.
2310 - var __wpdReactRoot = null;
2311 - var __wpdReactHost = null;
2312 -
2313 - function __wpdMountReactHarvester() {
2314 - if ( __wpdReactMounted ) return;
2315 - if ( ! window.wp || ! window.wp.element || ! window.wp.data ) {
2316 - return;
2317 - }
2318 - var el = window.wp.element;
2319 - var createEl = el.createElement;
2320 - var useEffect = el.useEffect;
2321 - var useRef = el.useRef;
2322 - var useMemo = el.useMemo;
2323 - var useSelect = ( window.wp.data && window.wp.data.useSelect ) || null;
2324 - if ( ! createEl || ! useSelect || ! el.createRoot || ! useRef ) {
2325 - return;
2326 - }
2327 - __wpdReactMounted = true;
2328 -
2329 - // Hidden mount point. Positioned off-screen + `aria-hidden` so
2330 - // nothing the harvester renders (it renders null anyway) can
2331 - // leak into the accessibility tree or the visible document.
2332 - var host = document.createElement( 'div' );
2333 - host.setAttribute( 'aria-hidden', 'true' );
2334 - host.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden;pointer-events:none;left:-9999px;top:-9999px;';
2335 - ( document.body || document.documentElement ).appendChild( host );
2336 - __wpdReactHost = host;
2337 -
2338 - // Shared mutable bucket — ref-based aggregation to avoid the
2339 - // classic setState-inside-useEffect loop. A `setState` here
2340 - // would fire a parent re-render, which would fire the loader
2341 - // hook again, which returns a fresh commands array with a new
2342 - // reference even when the contents are identical, which would
2343 - // re-fire the effect and setState again → Maximum update
2344 - // depth exceeded. Refs don't trigger renders, so the loop is
2345 - // broken even when hooks churn references.
2346 - var resultsBucket = { perLoader: {}, statics: [], loadersList: [] };
2347 -
2348 - function commandsFingerprint( cmds ) {
2349 - if ( ! Array.isArray( cmds ) || cmds.length === 0 ) return '';
2350 - // Cheap identity — name count is enough to decide whether
2351 - // to re-post. Accepts some false negatives (two different
2352 - // commands sharing a name) we'll never hit in practice.
2353 - var keys = new Array( cmds.length );
2354 - for ( var i = 0; i < cmds.length; i++ ) {
2355 - var c = cmds[ i ];
2356 - keys[ i ] = c && c.name ? c.name : '';
2357 - }
2358 - return keys.join( '|' );
2359 - }
2360 -
2361 - function mergeAndPost() {
2362 - var merged = [];
2363 - var loadersList = resultsBucket.loadersList;
2364 - if ( Array.isArray( loadersList ) ) {
2365 - for ( var i = 0; i < loadersList.length; i++ ) {
2366 - var bucket = resultsBucket.perLoader[ loadersList[ i ] ];
2367 - if ( Array.isArray( bucket ) ) merged = merged.concat( bucket );
2368 - }
2369 - }
2370 - if ( Array.isArray( resultsBucket.statics ) ) {
2371 - merged = merged.concat( resultsBucket.statics );
2372 - }
2373 - // Refresh the callback cache off the SAME snapshot we're
2374 - // about to post. Loader-returned commands close over React
2375 - // state (selected block, edited entity, etc.) that's only
2376 - // valid for this render pass, so rebuilding from scratch
2377 - // every merge keeps invoke-from-parent honest instead of
2378 - // calling a stale closure.
2379 - __wpdCommandCallbacks = Object.create( null );
2380 - for ( var j = 0; j < merged.length; j++ ) {
2381 - var cc = merged[ j ];
2382 - if ( cc && cc.name && typeof cc.callback === 'function' ) {
2383 - __wpdCommandCallbacks[ cc.name ] = cc.callback;
2384 - }
2385 - }
2386 - __wpdLastRawCommands = merged;
2387 - __wpdSchedulePost();
2388 - }
2389 -
2390 - // One slot per loader. Calls the loader's hook at render time;
2391 - // an effect keyed on the commands' name-fingerprint writes the
2392 - // fresh list into the shared bucket and posts. Ref-based, no
2393 - // setState → no re-render cascade.
2394 - function LoaderSlot( props ) {
2395 - var loader = props.loader;
2396 - var result = null;
2397 - try {
2398 - result = loader.hook( { search: '' } );
2399 - } catch ( _err ) {
2400 - /* swallow — a buggy loader hook shouldn't take the harvester down */
2401 - }
2402 - var cmds = ( result && Array.isArray( result.commands ) ) ? result.commands : [];
2403 - var key = useMemo( function () { return commandsFingerprint( cmds ); }, [ cmds ] );
2404 -
2405 - useEffect( function () {
2406 - resultsBucket.perLoader[ loader.name ] = cmds;
2407 - mergeAndPost();
2408 - }, [ key ] );
2409 -
2410 - useEffect( function () {
2411 - return function () {
2412 - delete resultsBucket.perLoader[ loader.name ];
2413 - mergeAndPost();
2414 - };
2415 - }, [] );
2416 -
2417 - return null;
2418 - }
2419 -
2420 - function Harvester() {
2421 - var loaders = useSelect( function ( s ) {
2422 - var ss = s( 'core/commands' );
2423 - return ( ss && typeof ss.getCommandLoaders === 'function' )
2424 - ? ss.getCommandLoaders( true )
2425 - : [];
2426 - }, [] );
2427 - var staticCmds = useSelect( function ( s ) {
2428 - var ss = s( 'core/commands' );
2429 - return ( ss && typeof ss.getCommands === 'function' )
2430 - ? ss.getCommands( true )
2431 - : [];
2432 - }, [] );
2433 -
2434 - // Track the loader-name ordering so `mergeAndPost` can emit
2435 - // tier-3 in a deterministic order (React reconciliation
2436 - // order = registration order = the order the user sees).
2437 - var loadersNames = useMemo( function () {
2438 - if ( ! Array.isArray( loaders ) ) return [];
2439 - return loaders.map( function ( l ) { return l ? l.name : ''; } );
2440 - }, [ loaders ] );
2441 - var loadersKey = loadersNames.join( '|' );
2442 - useEffect( function () {
2443 - resultsBucket.loadersList = loadersNames;
2444 - mergeAndPost();
2445 - }, [ loadersKey ] );
2446 -
2447 - var staticKey = useMemo( function () { return commandsFingerprint( staticCmds ); }, [ staticCmds ] );
2448 - useEffect( function () {
2449 - resultsBucket.statics = Array.isArray( staticCmds ) ? staticCmds : [];
2450 - mergeAndPost();
2451 - }, [ staticKey ] );
2452 -
2453 - if ( ! Array.isArray( loaders ) || loaders.length === 0 ) {
2454 - return null;
2455 - }
2456 - var children = [];
2457 - for ( var i = 0; i < loaders.length; i++ ) {
2458 - var loader = loaders[ i ];
2459 - if ( ! loader || typeof loader.hook !== 'function' ) continue;
2460 - children.push( createEl( LoaderSlot, {
2461 - key: loader.name,
2462 - loader: loader
2463 - } ) );
2464 - }
2465 - return createEl( el.Fragment || 'div', null, children );
2466 - }
2467 -
2468 - try {
2469 - var root = el.createRoot( host );
2470 - __wpdReactRoot = root;
2471 - root.render( createEl( Harvester ) );
2472 - } catch ( err ) {
2473 - __wpdReactMounted = false;
2474 - __wpdReactRoot = null;
2475 - if ( __wpdReactHost && __wpdReactHost.parentNode ) {
2476 - __wpdReactHost.parentNode.removeChild( __wpdReactHost );
2477 - }
2478 - __wpdReactHost = null;
2479 - }
2480 - }
2481 -
2482 - function __wpdUnmountReactHarvester() {
2483 - if ( __wpdReactRoot ) {
2484 - try { __wpdReactRoot.unmount(); } catch ( _err ) { /* swallow */ }
2485 - }
2486 - __wpdReactRoot = null;
2487 - if ( __wpdReactHost && __wpdReactHost.parentNode ) {
2488 - __wpdReactHost.parentNode.removeChild( __wpdReactHost );
2489 - }
2490 - __wpdReactHost = null;
2491 - __wpdReactMounted = false;
2492 - __wpdLastRawCommands = [];
2493 - __wpdCommandCallbacks = Object.create( null );
2494 - }
2495 -
2496 - function __wpdPostCommandsList() {
2497 - var list = __wpdHarvestCommands();
2498 - // Cheap de-dupe — the store fires on every unrelated preference
2499 - // change too, and shipping an identical payload is pure noise.
2500 - // Fingerprint on `name|kind|url` keeps us sensitive to the
2501 - // visible surface (name changes, navigate-vs-action flips,
2502 - // destination URL changes) while skipping `JSON.stringify` of
2503 - // the entire payload — label/icon churn inside a single command
2504 - // is rare and re-shipping on it is harmless noise vs. a hot
2505 - // path allocation cost.
2506 - var key = '';
2507 - for ( var k = 0; k < list.length; k++ ) {
2508 - var lc = list[ k ];
2509 - key += ( lc && lc.name ? lc.name : '' ) + '|'
2510 - + ( lc && lc.kind ? lc.kind : '' ) + '|'
2511 - + ( lc && lc.url ? lc.url : '' ) + '\n';
2512 - }
2513 - if ( key === __wpdCommandsLastPayload ) {
2514 - return;
2515 - }
2516 - __wpdCommandsLastPayload = key;
2517 - try {
2518 - window.parent.postMessage(
2519 - { type: 'os-commands-list', commands: list },
2520 - __wpdCommandsOrigin
2521 - );
2522 - } catch ( _err ) {
2523 - /* cross-origin parent (shouldn't happen for chromeless pages, but
2524 - * don't let a throw break the bridge) */
2525 - }
2526 - }
2527 -
2528 - function __wpdSchedulePost() {
2529 - if ( __wpdCommandsDebounceId !== null ) return;
2530 - __wpdCommandsDebounceId = window.setTimeout( function () {
2531 - __wpdCommandsDebounceId = null;
2532 - __wpdPostCommandsList();
2533 - }, 60 );
2534 - }
2535 -
2536 - function __wpdSubscribeCommands() {
2537 - __wpdCommandsSubscribed = true;
2538 -
2539 - // If the React harvester is already running (focus left and
2540 - // came back), the bucket still holds the latest merged list.
2541 - // Reset the dedupe key so the next post actually ships, then
2542 - // schedule it. The harvester itself won't re-fire its effects
2543 - // just because the parent re-subscribed — React only reacts to
2544 - // store changes, and the store hasn't changed. We have to
2545 - // push from here.
2546 - if ( __wpdReactMounted ) {
2547 - __wpdCommandsLastPayload = '';
2548 - __wpdSchedulePost();
2549 - return;
2550 - }
2551 -
2552 - var attempts = 0;
2553 - function tryBind() {
2554 - if ( ! __wpdCommandsSubscribed ) return;
2555 - if ( ! window.wp || ! window.wp.data || typeof window.wp.data.subscribe !== 'function' ) {
2556 - if ( attempts++ < 40 ) {
2557 - window.setTimeout( tryBind, 150 );
2558 - }
2559 - return;
2560 - }
2561 - // Mount the React harvester — tier 3 loaders are hooks and
2562 - // need a legal render context to execute. On every re-render
2563 - // the component's effect calls `__wpdSchedulePost` with the
2564 - // fresh merged list, so we don't need a separate
2565 - // `wp.data.subscribe` callback.
2566 - __wpdMountReactHarvester();
2567 - }
2568 - tryBind();
2569 - }
2570 -
2571 - function __wpdUnsubscribeCommands() {
2572 - __wpdCommandsSubscribed = false;
2573 - __wpdCommandsLastPayload = '';
2574 - if ( __wpdCommandsDebounceId !== null ) {
2575 - try { window.clearTimeout( __wpdCommandsDebounceId ); } catch ( _err ) { /* swallow */ }
2576 - __wpdCommandsDebounceId = null;
2577 - }
2578 - // Fully tear down the React harvester. Keeping it mounted in
2579 - // the background wastes CPU: every store tick re-renders the
2580 - // loader hooks, which rebuild the callback cache and post to
2581 - // the parent (who drops the message because this window isn't
2582 - // the subscribed one). On re-subscribe we remount from scratch.
2583 - __wpdUnmountReactHarvester();
2584 - }
2585 -
2586 - function __wpdInvokeCommand( name ) {
2587 - // Primary lookup — the React harvester's latest snapshot. This
2588 - // covers loader-returned commands (Duplicate block, Transform
2589 - // to, pattern commands) that never appear in the static
2590 - // `getCommands()` list.
2591 - var cb = __wpdCommandCallbacks[ name ];
2592 - if ( typeof cb === 'function' ) {
2593 - try {
2594 - cb( { close: function () {} } );
2595 - } catch ( _err ) {
2596 - /* swallow — a plugin command callback that throws shouldn't break the bridge */
2597 - }
2598 - return;
2599 - }
2600 - // Fallback — statically registered commands that never passed
2601 - // through the harvester (registered after the last render).
2602 - if ( ! window.wp || ! window.wp.data ) {
2603 - return;
2604 - }
2605 - var sel = null;
2606 - try { sel = window.wp.data.select( 'core/commands' ); } catch ( _err ) { return; }
2607 - if ( ! sel || typeof sel.getCommands !== 'function' ) return;
2608 - var raw;
2609 - try { raw = sel.getCommands(); } catch ( _err ) { return; }
2610 - if ( ! raw ) return;
2611 - for ( var i = 0; i < raw.length; i++ ) {
2612 - if ( raw[ i ] && raw[ i ].name === name && typeof raw[ i ].callback === 'function' ) {
2613 - try {
2614 - raw[ i ].callback( { close: function () {} } );
2615 - } catch ( _err ) {
2616 - /* swallow — see note in primary path above */
2617 - }
2618 - return;
2619 - }
2620 - }
2621 - }
2622 -
2623 - // Attach the listener BEFORE the bridge-ready ping so a subscribe
2624 - // posted synchronously in response is guaranteed to land.
2625 - window.addEventListener( 'message', function ( e ) {
2626 - if ( e.origin !== __wpdCommandsOrigin ) return;
2627 - if ( ! e.data || typeof e.data.type !== 'string' ) return;
2628 - if ( e.data.type === 'os-commands-subscribe' ) {
2629 - __wpdSubscribeCommands();
2630 - } else if ( e.data.type === 'os-commands-unsubscribe' ) {
2631 - __wpdUnsubscribeCommands();
2632 - } else if ( e.data.type === 'os-commands-invoke' && typeof e.data.name === 'string' ) {
2633 - __wpdInvokeCommand( e.data.name );
2634 - }
2635 - } );
2636 -
2637 - // Handshake: tell the parent we're ready so it can (re)send any
2638 - // subscribe that was dispatched before this listener attached.
2639 - // Without this ping, a subscribe posted during iframe navigation
2640 - // arrives at a context whose message listener isn't installed yet
2641 - // and is silently dropped — the symptom is an empty palette even
2642 - // though `wp.data.select('core/commands')` is perfectly happy.
2643 - try {
2644 - window.parent.postMessage(
2645 - { type: 'os-bridge-ready' },
2646 - __wpdCommandsOrigin
2647 - );
2648 - } catch ( _err ) {
2649 - /* parent gone or cross-origin — bridge handshake will retry on next load */
2650 - }
2651 -
2652 - /*
2653 - * Background heartbeat throttle.
2654 - *
2655 - * Every chromeless iframe is a complete wp-admin page running
2656 - * Core's Heartbeat — 15 s in the post editor (post locking). A
2657 - * desktop with several windows open therefore fires several
2658 - * admin-ajax heartbeats a minute from windows the user isn't even
2659 - * looking at, and every one of them boots the whole plugin stack
2660 - * server-side. Core only slows Heartbeat when the browser TAB is
2661 - * hidden; a background desktop window is still a visible iframe,
2662 - * so that built-in backoff never engages.
2663 - *
2664 - * The parent shell posts `os-window-active` on window focus and
2665 - * blur (`src/window-activity-notifier.ts`). On blur we stretch the
2666 - * interval to Heartbeat's 120 s maximum; on focus we restore the
2667 - * saved cadence. Post locks stay safe: Core's lock window is 150 s,
2668 - * above the slowed interval. Two guards keep this conservative:
2669 - *
2670 - * - Never slow an interval below 15 s — a 5 s cadence means
2671 - * something urgent (auth-check retry) is in flight.
2672 - * - Only restore when the interval is still the 120 s we set —
2673 - * if page code re-tuned Heartbeat while backgrounded, the
2674 - * throttle keeps its hands off.
2675 - */
2676 - ( function () {
2677 - var savedInterval = null;
2678 -
2679 - window.addEventListener( 'message', function ( e ) {
2680 - if ( e.origin !== window.location.origin || e.source !== window.parent ) return;
2681 - var d = e && e.data;
2682 - if ( ! d || typeof d !== 'object' || d.type !== 'os-window-active' ) return;
2683 - var hb = window.wp && window.wp.heartbeat;
2684 - if ( ! hb || typeof hb.interval !== 'function' ) return;
2685 - try {
2686 - if ( d.active === false ) {
2687 - var current = hb.interval();
2688 - if (
2689 - savedInterval === null &&
2690 - typeof current === 'number' &&
2691 - current >= 15 &&
2692 - current < 120
2693 - ) {
2694 - savedInterval = current;
2695 - hb.interval( 120 );
2696 - }
2697 - } else if ( d.active === true && savedInterval !== null ) {
2698 - if ( hb.interval() === 120 ) {
2699 - hb.interval( savedInterval );
2700 - }
2701 - savedInterval = null;
2702 - }
2703 - } catch ( _err ) {
2704 - /* heartbeat internals changed shape — leave it alone */
2705 - }
2706 - } );
2707 - } () );
2708 -
2709 - /*
2710 - * ` / Shift+` forwarder — window switcher.
2711 - *
2712 - * Bare backtick with no modifier. Must skip when focus is in a
2713 - * text-entry element, otherwise typing ` into a block, a text
2714 - * field, or TinyMCE would steal the keystroke. Non-text inputs
2715 - * (checkbox, button, select) don't accept character input, so
2716 - * cycling on those is fine.
2717 - *
2718 - * Same iframe-crossing rationale as the Cmd+K forwarder above:
2719 - * native keydown doesn't reach the parent, so we postMessage.
2720 - */
2721 - document.addEventListener( 'keydown', function ( e ) {
2722 - if ( e.ctrlKey || e.metaKey || e.altKey ) return;
2723 - if ( e.code !== 'Backquote' ) return;
2724 -
2725 - // IFRAME case catches Gutenberg: the block canvas is a nested
2726 - // iframe, and Gutenberg re-dispatches cloned keydowns up to
2727 - // this document for its shortcut system. Without this branch
2728 - // typing ` in a block would cycle windows. Any other nested
2729 - // iframe owning keyboard handling gets the same treatment.
2730 - var el = document.activeElement;
2731 - if ( el ) {
2732 - var tag = el.tagName;
2733 - if ( tag === 'IFRAME' ) return;
2734 - if ( tag === 'TEXTAREA' ) return;
2735 - if ( tag === 'INPUT' ) {
2736 - var type = ( el.type || '' ).toLowerCase();
2737 - var textTypes = [
2738 - 'text', 'search', 'url', 'email', 'password',
2739 - 'tel', 'number', 'date', 'datetime-local',
2740 - 'month', 'week', 'time'
2741 - ];
2742 - if ( textTypes.indexOf( type ) !== -1 ) return;
2743 - }
2744 - if ( el.isContentEditable ) return;
2745 - }
2746 -
2747 - e.preventDefault();
2748 - e.stopImmediatePropagation();
2749 -
2750 - try {
2751 - window.parent.postMessage(
2752 - {
2753 - type: 'os-window-switch',
2754 - direction: e.shiftKey ? 'prev' : 'next'
2755 - },
2756 - window.location.origin
2757 - );
2758 - } catch ( err ) { /* cross-origin parent; swallow */ }
2759 - }, true );
2760 -
2761 - // Skip if the standalone iframe-bridge bundle already wired
2762 - // screen-meta hoisting on this page. Two bridges racing to read
2763 - // `aria-expanded` and reflect state would double-fire the
2764 - // `os-screen-meta-state` message and flicker the
2765 - // title-bar buttons.
2766 - if ( window.__openStationScreenMetaInstalled ) {
2767 - return;
2768 - }
2769 - window.__openStationScreenMetaInstalled = true;
2770 -
2771 - // Real screen options render form controls (column toggles, a
2772 - // per-page input, custom settings). An empty wrap should not
2773 - // surface a dead gear button.
2774 - function hasScreenOptionsContent() {
2775 - var wrap = document.getElementById( 'screen-options-wrap' );
2776 - // WP always renders a nonce hidden input and an "Apply" submit
2777 - // inside the wrap, so match only interactive option controls
2778 - // (toggles, per-page, radios, selects) — never that always-
2779 - // present scaffolding — or an empty panel reads as non-empty.
2780 - return !! wrap && !! wrap.querySelector( 'input:not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]), select, textarea' );
2781 - }
2782 - // A help tab registered with empty content + no callback still
2783 - // produces #contextual-help-link but an empty panel. Require some
2784 - // non-whitespace tab/sidebar text before announcing the button.
2785 - function hasHelpContent() {
2786 - var wrap = document.getElementById( 'contextual-help-wrap' );
2787 - if ( ! wrap ) {
2788 - return false;
2789 - }
2790 - var panelEls = wrap.querySelectorAll( '.help-tab-content, .contextual-help-sidebar' );
2791 - for ( var i = 0; i < panelEls.length; i++ ) {
2792 - if ( ( panelEls[ i ].textContent || '' ).trim() !== '' ) {
2793 - return true;
2794 - }
2795 - }
2796 - return false;
2797 - }
2798 -
2799 - var links = document.getElementById( 'screen-meta-links' );
2800 - var screenOptionsBtn = links ? document.getElementById( 'show-settings-link' ) : null;
2801 - var helpBtn = links ? document.getElementById( 'contextual-help-link' ) : null;
2802 - var panels = [];
2803 - if ( screenOptionsBtn && hasScreenOptionsContent() ) {
2804 - panels.push( 'screen-options' );
2805 - }
2806 - if ( helpBtn && hasHelpContent() ) {
2807 - panels.push( 'help' );
2808 - }
2809 -
2810 - var origin = window.location.origin;
2811 -
2812 - // ALWAYS announce — including an empty array — so the parent removes
2813 - // stale gear/Help buttons when this page (e.g. after an in-place
2814 - // same-slug navigation) has no screen meta. addScreenMetaButtons()
2815 - // clears then repopulates, so an empty array removes everything.
2816 - window.parent.postMessage( {
2817 - type: 'os-screen-meta',
2818 - panels: panels
2819 - }, origin );
2820 -
2821 - if ( panels.length === 0 ) {
2822 - return;
2823 - }
2824 -
2825 - function getOpenPanel() {
2826 - if ( screenOptionsBtn && screenOptionsBtn.getAttribute( 'aria-expanded' ) === 'true' ) {
2827 - return 'screen-options';
2828 - }
2829 - if ( helpBtn && helpBtn.getAttribute( 'aria-expanded' ) === 'true' ) {
2830 - return 'help';
2831 - }
2832 - return null;
2833 - }
2834 -
2835 - function reportState() {
2836 - window.parent.postMessage( {
2837 - type: 'os-screen-meta-state',
2838 - open: getOpenPanel()
2839 - }, origin );
2840 - }
2841 -
2842 - reportState();
2843 -
2844 - var observer = new MutationObserver( reportState );
2845 - if ( screenOptionsBtn ) {
2846 - observer.observe( screenOptionsBtn, { attributes: true, attributeFilter: [ 'aria-expanded' ] } );
2847 - }
2848 - if ( helpBtn ) {
2849 - observer.observe( helpBtn, { attributes: true, attributeFilter: [ 'aria-expanded' ] } );
2850 - }
2851 -
2852 - // WP's close() animates and shares #screen-meta between both panels,
2853 - // so racing two animated clicks hides the panel that just opened.
2854 - // Jump the other panel to its closed end state synchronously instead.
2855 - function forceClose( button ) {
2856 - if ( ! button || button.getAttribute( 'aria-expanded' ) !== 'true' ) {
2857 - return;
2858 - }
2859 - var panelId = button.getAttribute( 'aria-controls' );
2860 - var panel = panelId ? document.getElementById( panelId ) : null;
2861 - if ( ! panel ) {
2862 - return;
2863 - }
2864 - if ( window.jQuery ) {
2865 - window.jQuery( panel ).stop( true, false );
2866 - }
2867 - panel.style.display = 'none';
2868 - panel.classList.add( 'hidden' );
2869 - if ( panel.parentNode instanceof HTMLElement ) {
2870 - panel.parentNode.style.display = 'none';
2871 - }
2872 - button.classList.remove( 'screen-meta-active' );
2873 - button.setAttribute( 'aria-expanded', 'false' );
2874 - var toggles = document.querySelectorAll( '.screen-meta-toggle' );
2875 - for ( var i = 0; i < toggles.length; i++ ) {
2876 - toggles[ i ].style.visibility = '';
2877 - }
2878 - }
2879 -
2880 - /* -----------------------------------------------------------------
2881 - * Broadcast receiver — iframe side.
2882 - *
2883 - * The parent shell publishes broadcasts via
2884 - * `wp.os.broadcast(topic, payload)` (see `src/broadcast.ts`).
2885 - * It posts `{ type: 'os-broadcast', topic, payload }` to
2886 - * every open iframe. Here we re-dispatch that as a CustomEvent
2887 - * on the iframe's own document so admin pages can subscribe with
2888 - * plain `document.addEventListener( 'os-broadcast', cb )`
2889 - * — no extra script handle required.
2890 - *
2891 - * Iframe-side admin code can also publish UPSTREAM by posting
2892 - * the same shape to `window.parent`; the parent's
2893 - * `installBroadcastReceiver()` re-broadcasts to every other
2894 - * iframe + native window.
2895 - * ----------------------------------------------------------------- */
2896 - window.addEventListener( 'message', function ( e ) {
2897 - if ( e.origin !== origin ) {
2898 - return;
2899 - }
2900 - if ( ! e.data || e.data.type !== 'os-broadcast' ) {
2901 - return;
2902 - }
2903 - try {
2904 - document.dispatchEvent( new CustomEvent( 'os-broadcast', {
2905 - detail: { topic: e.data.topic, payload: e.data.payload }
2906 - } ) );
2907 - } catch ( _err ) { /* old browser without CustomEvent ctor — ignore */ }
2908 - } );
2909 -
2910 - /* -----------------------------------------------------------------
2911 - * Soft-reload — iframe-side default handler.
2912 - *
2913 - * When a `os.<post_type>.changed` broadcast fires AND the
2914 - * current iframe is on a known list page for that post type, we
2915 - * fetch the current URL and replace the iframe's `#wpbody-content`
2916 - * in place. The user sees the new state of the list — restored
2917 - * post appears, deleted media disappears — without the WP loading
2918 - * spinner that `location.reload()` would show.
2919 - *
2920 - * Single-edit pages (`post.php`, `post-new.php`, the HPOS order
2921 - * editor) are deliberately NOT matched: replacing their body would
2922 - * destroy any unsaved Gutenberg/classic-editor state. Plugins that
2923 - * want specific behaviour for those pages can subscribe to the
2924 - * same topic on `document` and handle it themselves.
2925 - *
2926 - * Matching is generic: the current page's "list type" is derived
2927 - * from the URL (`edit.php` → its `post_type` param or `post`,
2928 - * `upload.php` → `attachment`, `edit-comments.php` → `comment`)
2929 - * and compared against the `<type>` captured from any
2930 - * `os.<type>.changed` topic — so every custom post
2931 - * type's `edit.php?post_type=X` screen participates with zero
2932 - * per-type code. Non-`edit.php` list screens (e.g. WooCommerce's
2933 - * HPOS `admin.php?page=wc-orders`) are covered by declarative
2934 - * extra rules, filterable server-side via
2935 - * `openstation_soft_reload_rules`.
2936 - *
2937 - * The fetch carries a custom header so a later phase can serve a
2938 - * minimal partial response if we want to optimise; for now WP
2939 - * returns the full admin page and we just pluck the body.
2940 - *
2941 - * Most WP list-table JS delegates on `document`/`body` and
2942 - * survives the swap. The inline editors don't, so
2943 - * `_openstationReinitListTables()` below re-runs Core's init
2944 - * entry points afterwards. A page needing more than that should
2945 - * listen for `os-soft-reloaded`, which fires after that re-init.
2946 - * ----------------------------------------------------------------- */
2947 - var OPENSTATION_SOFT_RELOAD_EXTRAS = /*__OPENSTATION_SOFT_RELOAD_EXTRAS__*/;
2948 -
2949 - function _openstationEndsWith( s, suffix ) { return s.lastIndexOf( suffix ) === s.length - suffix.length; }
2950 -
2951 - function _openstationListType() {
2952 - if ( _openstationEndsWith( location.pathname, '/wp-admin/edit.php' ) ) {
2953 - return new URLSearchParams( location.search ).get( 'post_type' ) || 'post';
2954 - }
2955 - if ( _openstationEndsWith( location.pathname, '/wp-admin/upload.php' ) ) {
2956 - return 'attachment';
2957 - }
2958 - if ( _openstationEndsWith( location.pathname, '/wp-admin/edit-comments.php' ) ) {
2959 - return 'comment';
2960 - }
2961 - if ( _openstationEndsWith( location.pathname, '/wp-admin/plugins.php' ) ) {
2962 - return 'plugin';
2963 - }
2964 - // plugin-install.php is intentionally not a soft-reload target.
2965 - // Reloading that page mid-session would discard the user's search
2966 - // results or reset an in-progress install. The page still emits
2967 - // plugin.changed (via notifyPluginInstall below); it just doesn't
2968 - // reload itself in response to one.
2969 - return null;
2970 - }
2971 -
2972 - function _openstationMatchesExtraRule( rule ) {
2973 - if ( ! rule || ! rule.path ) {
2974 - return false;
2975 - }
2976 - if ( ! _openstationEndsWith( location.pathname, '/wp-admin/' + rule.path ) ) {
2977 - return false;
2978 - }
2979 - var params = new URLSearchParams( location.search );
2980 - if ( rule.query ) {
2981 - for ( var key in rule.query ) {
2982 - if ( ! Object.prototype.hasOwnProperty.call( rule.query, key ) ) {
2983 - continue;
2984 - }
2985 - if ( params.get( key ) !== String( rule.query[ key ] ) ) {
2986 - return false;
2987 - }
2988 - }
2989 - }
2990 - if ( rule.queryAbsent ) {
2991 - for ( var i = 0; i < rule.queryAbsent.length; i++ ) {
2992 - if ( params.has( rule.queryAbsent[ i ] ) ) {
2993 - return false;
2994 - }
2995 - }
2996 - }
2997 - return true;
2998 - }
2999 -
3000 - function _openstationSoftReloadTopicMatches( topic ) {
3001 - var m = /^os\.(.+)\.changed$/.exec( topic );
3002 - if ( m && m[ 1 ] === _openstationListType() ) {
3003 - return true;
3004 - }
3005 - for ( var i = 0; i < OPENSTATION_SOFT_RELOAD_EXTRAS.length; i++ ) {
3006 - var rule = OPENSTATION_SOFT_RELOAD_EXTRAS[ i ];
3007 - if ( rule && rule.topic === topic && _openstationMatchesExtraRule( rule ) ) {
3008 - return true;
3009 - }
3010 - }
3011 - return false;
3012 - }
3013 -
3014 - var _openstationSoftReloadInFlight = false;
3015 - var _openstationSoftReloadQueued = false;
3016 -
3017 - /*
3018 - * Re-init Core's list-table editors after a soft reload.
3019 - *
3020 - * Core's inline editors bind to elements inside `#wpbody-content`
3021 - * instead of delegating on `document`: `#the-list` for Quick Edit
3022 - * (inline-edit-post.js, inline-edit-tax.js), `#doaction` for Bulk
3023 - * Edit, `#the-comment-list` for the comment inline editors. The
3024 - * swap above throws those elements away, so the buttons keep
3025 - * rendering and stop working.
3026 - *
3027 - * Only re-run an init whose every binding lands inside the
3028 - * replaced subtree — then the fresh DOM gets it exactly once and
3029 - * nothing accumulates. `setCommentsList()` fails that test and is
3030 - * NOT called here: it re-runs `wpList`, whose `process()` binds on
3031 - * `document` (which survives), so each call stacks another set of
3032 - * comment row-action handlers and one Approve click ends up firing
3033 - * N moderation requests. Those handlers were never broken by the
3034 - * swap; only the closure state behind them goes stale, which costs
3035 - * a stale total count until the next reload.
3036 - *
3037 - * Also left alone: `common.js`'s empty-bulk-action guard and
3038 - * search-box mousedown, and `$.table_hotkeys` (comment moderation
3039 - * shortcuts stop navigating; re-running it would double-register).
3040 - * All degraded rather than dead, and each would mean copying
3041 - * dozens of lines of Core in here.
3042 - */
3043 - function _openstationReinitListTables() {
3044 - var $ = window.jQuery;
3045 - if ( ! $ ) {
3046 - return;
3047 - }
3048 -
3049 - /*
3050 - * Mobile row expander. `common.js` binds it per-`tbody`, all
3051 - * of which we just replaced. Narrow windows are the norm in
3052 - * the shell, so it is often the only row affordance on screen.
3053 - *
3054 - * Per-`tbody` on purpose. Delegating on the now-surviving
3055 - * `#wpbody-content` would stack with Core's own binding on
3056 - * first load and toggle the row twice, back to closed.
3057 - */
3058 - $( '#wpbody-content tbody' ).on( 'click', '.toggle-row', function () {
3059 - $( this ).closest( 'tr' ).toggleClass( 'is-expanded' );
3060 - } );
3061 -
3062 - if ( document.getElementById( 'the-list' ) ) {
3063 - try {
3064 - if ( window.inlineEditPost && typeof window.inlineEditPost.init === 'function' ) {
3065 - window.inlineEditPost.init();
3066 - }
3067 - } catch ( err ) { _openstationWarnReinit( 'inline-edit-post', err ); }
3068 - try {
3069 - if ( window.inlineEditTax && typeof window.inlineEditTax.init === 'function' ) {
3070 - window.inlineEditTax.init();
3071 - }
3072 - } catch ( err ) { _openstationWarnReinit( 'inline-edit-tax', err ); }
3073 - }
3074 -
3075 - if ( document.getElementById( 'the-comment-list' ) && window.commentReply ) {
3076 - try {
3077 - if ( typeof window.commentReply.init === 'function' ) {
3078 - window.commentReply.init();
3079 - }
3080 - } catch ( err ) { _openstationWarnReinit( 'comment-reply', err ); }
3081 - try {
3082 - /*
3083 - * Quick Edit / Reply / Edit on a comment row.
3084 - * edit-comments.js binds this in its own doc-ready
3085 - * rather than in `commentReply.init`, so there is
3086 - * nothing to re-call. Mirror Core's handler.
3087 - */
3088 - $( '#the-comment-list' ).on( 'click', '.comment-inline', function () {
3089 - var $el = $( this ),
3090 - action = 'replyto';
3091 -
3092 - if ( 'undefined' !== typeof $el.data( 'action' ) ) {
3093 - action = $el.data( 'action' );
3094 - }
3095 -
3096 - $( this ).attr( 'aria-expanded', 'true' );
3097 - window.commentReply.open( $el.data( 'commentId' ), $el.data( 'postId' ), action );
3098 - } );
3099 - } catch ( err ) { _openstationWarnReinit( 'comment-inline', err ); }
3100 - }
3101 - }
3102 -
3103 - /*
3104 - * One failing re-init must not take the others, or the
3105 - * `os-soft-reloaded` listeners after them, down with it. Warn
3106 - * rather than swallow: a silent catch here looks exactly like the
3107 - * bug this function exists to fix.
3108 - */
3109 - function _openstationWarnReinit( which, err ) {
3110 - if ( window.console && window.console.warn ) {
3111 - window.console.warn( '[openstation] soft-reload re-init failed for ' + which + ':', err );
3112 - }
3113 - }
3114 -
3115 - function _openstationSoftReload() {
3116 - if ( _openstationSoftReloadInFlight ) {
3117 - _openstationSoftReloadQueued = true;
3118 - return;
3119 - }
3120 - _openstationSoftReloadInFlight = true;
3121 - fetch( location.href, {
3122 - credentials: 'same-origin',
3123 - cache: 'no-cache',
3124 - headers: { 'X-WP-Desktop-Soft-Reload': '1' }
3125 - } ).then( function ( r ) {
3126 - if ( ! r.ok ) throw new Error( 'soft-reload fetch failed: ' + r.status );
3127 - return r.text();
3128 - } ).then( function ( html ) {
3129 - var doc = new DOMParser().parseFromString( html, 'text/html' );
3130 - var fresh = doc.querySelector( '#wpbody-content' );
3131 - var live = document.querySelector( '#wpbody-content' );
3132 - if ( ! fresh || ! live ) {
3133 - /* Markup we expected isn't there — admin pages we
3134 - * don't recognise (or core changes the structure).
3135 - * Don't reload; let the iframe stay as it is rather
3136 - * than show a spinner the user told us not to. */
3137 - return;
3138 - }
3139 - /*
3140 - * Swap the CONTENTS of `#wpbody-content`, keeping the
3141 - * node. Keeping it preserves handlers delegated on it,
3142 - * which is where `common.js` puts the row-actions focus
3143 - * reveal. Core emits the container as a bare
3144 - * `<div id="wpbody-content">`, so the only thing this
3145 - * discards is any attribute a plugin added to `fresh`.
3146 - */
3147 - live.replaceChildren.apply( live, Array.prototype.slice.call( fresh.childNodes ) );
3148 - _openstationReinitListTables();
3149 - try {
3150 - document.dispatchEvent( new CustomEvent( 'os-soft-reloaded' ) );
3151 - } catch ( _err ) {}
3152 - /* Some WP scripts re-init on DOMContentLoaded only — let
3153 - * pages opt-in to a re-init by listening to the event
3154 - * above. We intentionally do NOT re-fire DOMContentLoaded;
3155 - * that's almost always wrong (double-init of jQuery/WP). */
3156 - } ).catch( function ( err ) {
3157 - /* Network error — leave the iframe untouched. The user's
3158 - * next manual interaction will refresh state, and the
3159 - * next broadcast will retry. */
3160 - if ( window.console && window.console.warn ) {
3161 - window.console.warn( '[openstation] soft-reload skipped:', err );
3162 - }
3163 - } ).then( function () {
3164 - _openstationSoftReloadInFlight = false;
3165 - if ( _openstationSoftReloadQueued ) {
3166 - _openstationSoftReloadQueued = false;
3167 - _openstationSoftReload();
3168 - }
3169 - } );
3170 - }
3171 -
3172 - document.addEventListener( 'os-broadcast', function ( e ) {
3173 - var detail = e.detail || {};
3174 - var topic = detail.topic;
3175 - if ( ! topic ) return;
3176 - if ( _openstationSoftReloadTopicMatches( topic ) ) {
3177 - _openstationSoftReload();
3178 - }
3179 - } );
3180 -
3181 - window.addEventListener( 'message', function( e ) {
3182 - if ( e.origin !== origin ) {
3183 - return;
3184 - }
3185 - if ( ! e.data || e.data.type !== 'os-toggle-panel' ) {
3186 - return;
3187 - }
3188 - var target = null;
3189 - if ( e.data.panel === 'screen-options' && screenOptionsBtn ) {
3190 - target = screenOptionsBtn;
3191 - } else if ( e.data.panel === 'help' && helpBtn ) {
3192 - target = helpBtn;
3193 - }
3194 - if ( ! target ) {
3195 - return;
3196 - }
3197 - if ( target.getAttribute( 'aria-expanded' ) !== 'true' ) {
3198 - var other = target === screenOptionsBtn ? helpBtn : screenOptionsBtn;
3199 - forceClose( other );
3200 - }
3201 - target.click();
3202 - } );
3203 -
3204 - /* -----------------------------------------------------------------
3205 - * Connection bridge — iframe side.
3206 - *
3207 - * Plugins call `wp.os.iframe.publish(topic, payload)` /
3208 - * `subscribe(topic, cb)` / `onConnection(cb)` to talk to a parent-
3209 - * side `wp.os.connect()` caller. The shell only routes;
3210 - * topic semantics are plugin-defined.
3211 - *
3212 - * Connections are tracked locally so `onConnection` can fire when
3213 - * the parent opens a new channel (typical use: start emitting
3214 - * heavy events only after at least one consumer subscribed). Each
3215 - * connection carries a topic-allowlist negotiated at handshake
3216 - * time — wildcard ('*') subscribers see everything.
3217 - * ----------------------------------------------------------------- */
3218 - var _wpdConnections = {};
3219 - var _wpdConnectionListeners = [];
3220 - var _wpdSubs = {}; // topic → [cb, ...]
3221 - var _wpdChannelSubs = {}; // channel → [cb, ...] (window-channel API)
3222 - var _wpdParentOrigin = window.location.origin;
3223 - var _wpdWindowId = null; // host window id, from the handshake
3224 - var _wpdWindowIdWaiters = []; // pending whenWindowId() resolvers
3225 -
3226 - /* Stash the host window's id (the parent's handshake carries
3227 - * `targetWindowId`) and flush any `whenWindowId()` waiters. Same
3228 - * contract as `assets/js/iframe-bridge.js`. */
3229 - function _wpdSetWindowId( id ) {
3230 - if ( ! id || _wpdWindowId === id ) {
3231 - return;
3232 - }
3233 - _wpdWindowId = id;
3234 - var waiters = _wpdWindowIdWaiters.splice( 0 );
3235 - for ( var i = 0; i < waiters.length; i++ ) {
3236 - try {
3237 - waiters[ i ]( id );
3238 - } catch ( _err ) { /* swallow */ }
3239 - }
3240 - }
3241 -
3242 - function _wpdEmitToParent( connectionId, topic, payload ) {
3243 - try {
3244 - window.parent.postMessage( {
3245 - type: 'os-bridge-publish',
3246 - connectionId: connectionId,
3247 - topic: topic,
3248 - payload: payload
3249 - }, _wpdParentOrigin );
3250 - } catch ( _err ) { /* parent gone */ }
3251 - }
3252 -
3253 - window.addEventListener( 'message', function ( ev ) {
3254 - if ( ev.origin !== _wpdParentOrigin ) {
3255 - return;
3256 - }
3257 - var data = ev && ev.data;
3258 - if ( ! data || typeof data !== 'object' || typeof data.type !== 'string' ) {
3259 - return;
3260 - }
3261 -
3262 - if ( data.type === 'os-bridge-beforeunload-query' ) {
3263 - var prevent = false;
3264 - var msg = '';
3265 -
3266 - function shimReturnValue( ev ) {
3267 - Object.defineProperty( ev, 'returnValue', {
3268 - get: function() { return this._returnValue || ''; },
3269 - set: function( v ) { this._returnValue = v; }
3270 - } );
3271 - }
3272 -
3273 - function checkPrevent( ev, result ) {
3274 - var hasRes = typeof result === 'string' && result !== '';
3275 - var hasRetVal = typeof ev.returnValue === 'string' && ev.returnValue !== '';
3276 - if ( ev.defaultPrevented || hasRes || hasRetVal ) {
3277 - prevent = true;
3278 - if ( hasRes ) {
3279 - msg = result;
3280 - } else if ( hasRetVal ) {
3281 - msg = ev.returnValue;
3282 - }
3283 - }
3284 - }
3285 -
3286 - var unloadEvent;
3287 - try {
3288 - unloadEvent = new Event( 'beforeunload', { cancelable: true } );
3289 - } catch ( _err ) {
3290 - unloadEvent = document.createEvent( 'Event' );
3291 - unloadEvent.initEvent( 'beforeunload', false, true );
3292 - }
3293 - shimReturnValue( unloadEvent );
3294 -
3295 - if ( typeof window.onbeforeunload === 'function' ) {
3296 - var res = window.onbeforeunload( unloadEvent );
3297 - checkPrevent( unloadEvent, res );
3298 - }
3299 - if ( ! prevent ) {
3300 - var dispatchEvent;
3301 - try {
3302 - dispatchEvent = new Event( 'beforeunload', { cancelable: true } );
3303 - } catch ( _err ) {
3304 - dispatchEvent = document.createEvent( 'Event' );
3305 - dispatchEvent.initEvent( 'beforeunload', false, true );
3306 - }
3307 - shimReturnValue( dispatchEvent );
3308 - window.dispatchEvent( dispatchEvent );
3309 - checkPrevent( dispatchEvent, null );
3310 - }
3311 -
3312 - try {
3313 - window.parent.postMessage( {
3314 - type: 'os-bridge-beforeunload-response',
3315 - prevent: prevent,
3316 - message: msg
3317 - }, _wpdParentOrigin );
3318 - } catch ( _err ) { /* swallow */ }
3319 - return;
3320 - }
3321 -
3322 - if ( data.type === 'os-bridge-handshake' && typeof data.connectionId === 'string' ) {
3323 - /* The parent's handshake carries the host window id —
3324 - * stash it so `wp.os.iframe.windowId` and
3325 - * `whenWindowId()` can serve callers that need to know
3326 - * which native window opened this iframe. */
3327 - if ( typeof data.targetWindowId === 'string' && data.targetWindowId !== '' ) {
3328 - _wpdSetWindowId( data.targetWindowId );
3329 - }
3330 - if ( _wpdConnections[ data.connectionId ] ) {
3331 - /* Re-handshake on iframe-ready re-arm — no-op besides
3332 - * acking again so the parent can resume. */
3333 - try {
3334 - window.parent.postMessage( {
3335 - type: 'os-bridge-handshake-ack',
3336 - connectionId: data.connectionId
3337 - }, _wpdParentOrigin );
3338 - } catch ( _err ) { /* swallow */ }
3339 - return;
3340 - }
3341 - var conn = {
3342 - id: data.connectionId,
3343 - topics: Array.isArray( data.topics ) ? data.topics.slice() : []
3344 - };
3345 - _wpdConnections[ conn.id ] = conn;
3346 - try {
3347 - window.parent.postMessage( {
3348 - type: 'os-bridge-handshake-ack',
3349 - connectionId: conn.id
3350 - }, _wpdParentOrigin );
3351 - } catch ( _err ) { /* swallow */ }
3352 - for ( var i = 0; i < _wpdConnectionListeners.length; i++ ) {
3353 - try {
3354 - _wpdConnectionListeners[ i ]( {
3355 - id: conn.id,
3356 - topics: conn.topics.slice()
3357 - } );
3358 - } catch ( _err ) { /* swallow listener */ }
3359 - }
3360 - return;
3361 - }
3362 -
3363 - if ( data.type === 'os-bridge-publish' && typeof data.topic === 'string' ) {
3364 - var bucket = _wpdSubs[ data.topic ];
3365 - if ( bucket ) {
3366 - for ( var j = 0; j < bucket.length; j++ ) {
3367 - try {
3368 - bucket[ j ]( data.payload, { topic: data.topic, connectionId: data.connectionId } );
3369 - } catch ( _err ) { /* swallow subscriber */ }
3370 - }
3371 - }
3372 - var wildcard = _wpdSubs[ '*' ];
3373 - if ( wildcard ) {
3374 - for ( var k = 0; k < wildcard.length; k++ ) {
3375 - try {
3376 - wildcard[ k ]( data.payload, { topic: data.topic, connectionId: data.connectionId } );
3377 - } catch ( _err ) { /* swallow */ }
3378 - }
3379 - }
3380 - return;
3381 - }
3382 -
3383 - if ( data.type === 'os-bridge-disconnect' && typeof data.connectionId === 'string' ) {
3384 - delete _wpdConnections[ data.connectionId ];
3385 - return;
3386 - }
3387 -
3388 - /* Unified window-channel delivery from the parent. Fires
3389 - * every `wp.os.on( channel, cb )` subscriber for the
3390 - * matching channel — same protocol as
3391 - * `assets/js/iframe-bridge.js`. */
3392 - if ( data.type === 'os-window-send' && typeof data.channel === 'string' && data.channel !== '' ) {
3393 - var meta = { channel: data.channel };
3394 - var cBucket = _wpdChannelSubs[ data.channel ];
3395 - if ( cBucket ) {
3396 - var cBucketSnap = cBucket.slice();
3397 - for ( var ci = 0; ci < cBucketSnap.length; ci++ ) {
3398 - try {
3399 - cBucketSnap[ ci ]( data.payload, meta );
3400 - } catch ( _err ) { /* swallow */ }
3401 - }
3402 - }
3403 - var cWildcard = _wpdChannelSubs[ '*' ];
3404 - if ( cWildcard ) {
3405 - var cWildcardSnap = cWildcard.slice();
3406 - for ( var cw = 0; cw < cWildcardSnap.length; cw++ ) {
3407 - try {
3408 - cWildcardSnap[ cw ]( data.payload, meta );
3409 - } catch ( _err ) { /* swallow */ }
3410 - }
3411 - }
3412 - return;
3413 - }
3414 - } );
3415 -
3416 - var iframeApi = {
3417 - /**
3418 - * Publish a payload under a topic. Sent to every connection
3419 - * — typical case is one connection per parent caller, but
3420 - * a debug console may have several at once.
3421 - */
3422 - publish: function ( topic, payload ) {
3423 - if ( typeof topic !== 'string' || topic === '' ) {
3424 - return;
3425 - }
3426 - var ids = Object.keys( _wpdConnections );
3427 - for ( var i = 0; i < ids.length; i++ ) {
3428 - _wpdEmitToParent( ids[ i ], topic, payload );
3429 - }
3430 - },
3431 - /**
3432 - * Subscribe to a topic. Returns an unsubscribe function.
3433 - * Use `'*'` to receive every published payload (debugging).
3434 - */
3435 - subscribe: function ( topic, cb ) {
3436 - if ( typeof topic !== 'string' || topic === '' || typeof cb !== 'function' ) {
3437 - return function () {};
3438 - }
3439 - var bucket = _wpdSubs[ topic ];
3440 - if ( ! bucket ) {
3441 - bucket = [];
3442 - _wpdSubs[ topic ] = bucket;
3443 - }
3444 - bucket.push( cb );
3445 - return function () {
3446 - var i = bucket.indexOf( cb );
3447 - if ( i >= 0 ) {
3448 - bucket.splice( i, 1 );
3449 - }
3450 - };
3451 - },
3452 - /**
3453 - * Notified whenever a parent caller opens a connection. Use
3454 - * to start emitting heavy publish events only when somebody
3455 - * is listening.
3456 - */
3457 - onConnection: function ( cb ) {
3458 - if ( typeof cb !== 'function' ) {
3459 - return function () {};
3460 - }
3461 - _wpdConnectionListeners.push( cb );
3462 - /* Replay current connections — late subscribers still
3463 - * see who's already there. */
3464 - var ids = Object.keys( _wpdConnections );
3465 - for ( var i = 0; i < ids.length; i++ ) {
3466 - try {
3467 - cb( {
3468 - id: _wpdConnections[ ids[ i ] ].id,
3469 - topics: _wpdConnections[ ids[ i ] ].topics.slice()
3470 - } );
3471 - } catch ( _err ) { /* swallow */ }
3472 - }
3473 - return function () {
3474 - var i = _wpdConnectionListeners.indexOf( cb );
3475 - if ( i >= 0 ) {
3476 - _wpdConnectionListeners.splice( i, 1 );
3477 - }
3478 - };
3479 - },
3480 - /**
3481 - * Iframe-initiated connection request. See
3482 - * `assets/js/iframe-bridge.js` — same shape, same protocol.
3483 - */
3484 - requestConnection: function ( opts ) {
3485 - opts = opts || {};
3486 - var topics = Array.isArray( opts.topics ) ? opts.topics.slice() : [];
3487 - var requestId = 'wpdir-' + Math.random().toString( 36 ).slice( 2, 10 );
3488 -
3489 - return new Promise( function ( resolve, reject ) {
3490 - var settled = false;
3491 - var timeoutMs = typeof opts.timeoutMs === 'number'
3492 - ? opts.timeoutMs
3493 - : 5000;
3494 -
3495 - function settle( ok, value ) {
3496 - if ( settled ) {
3497 - return;
3498 - }
3499 - settled = true;
3500 - window.removeEventListener( 'message', onAck );
3501 - clearTimeout( timer );
3502 - if ( ok ) {
3503 - resolve( value );
3504 - } else {
3505 - reject( value );
3506 - }
3507 - }
3508 -
3509 - function onAck( ev ) {
3510 - if ( ev.origin !== _wpdParentOrigin ) {
3511 - return;
3512 - }
3513 - var d = ev && ev.data;
3514 - if (
3515 - ! d ||
3516 - typeof d !== 'object' ||
3517 - d.type !== 'os-bridge-connection-ack' ||
3518 - d.requestId !== requestId
3519 - ) {
3520 - return;
3521 - }
3522 - if ( d.accepted ) {
3523 - var summary = {
3524 - id: typeof d.connectionId === 'string' ? d.connectionId : '',
3525 - topics: topics.slice()
3526 - };
3527 - if ( typeof opts.onOpen === 'function' ) {
3528 - try { opts.onOpen( summary ); } catch ( _err ) { /* swallow */ }
3529 - }
3530 - settle( true, summary );
3531 - } else {
3532 - settle( false, new Error( d.reason || 'rejected' ) );
3533 - }
3534 - }
3535 - window.addEventListener( 'message', onAck );
3536 -
3537 - var timer = setTimeout( function () {
3538 - settle( false, new Error( 'timeout' ) );
3539 - }, timeoutMs );
3540 -
3541 - try {
3542 - window.parent.postMessage( {
3543 - type: 'os-bridge-connection-request',
3544 - requestId: requestId,
3545 - topics: topics
3546 - }, _wpdParentOrigin );
3547 - } catch ( err ) {
3548 - settle( false, err );
3549 - }
3550 - } );
3551 - },
3552 - /**
3553 - * Window-chrome helpers. See `assets/js/iframe-bridge.js` —
3554 - * same shape, same protocol. `setSlot` is HTML-only
3555 - * (sandboxed via `textContent` on the parent side).
3556 - */
3557 - chrome: {
3558 - setTheme: function ( tokens ) {
3559 - try {
3560 - window.parent.postMessage( {
3561 - type: 'os-chrome-theme',
3562 - tokens: tokens || {}
3563 - }, _wpdParentOrigin );
3564 - } catch ( _err ) { /* parent gone */ }
3565 - },
3566 - setControls: function ( config ) {
3567 - try {
3568 - window.parent.postMessage( {
3569 - type: 'os-chrome-controls',
3570 - config: config === undefined ? null : config
3571 - }, _wpdParentOrigin );
3572 - } catch ( _err ) { /* parent gone */ }
3573 - },
3574 - setSlot: function ( name, html ) {
3575 - if ( typeof name !== 'string' || name === '' ) {
3576 - return;
3577 - }
3578 - try {
3579 - window.parent.postMessage( {
3580 - type: 'os-chrome-slot',
3581 - slot: name,
3582 - html: typeof html === 'string' ? html : ''
3583 - }, _wpdParentOrigin );
3584 - } catch ( _err ) { /* parent gone */ }
3585 - }
3586 - },
3587 - /**
3588 - * The id of the window the parent shell opened to host this
3589 - * iframe. Populated by the first connection handshake (the
3590 - * parent's handshake carries `targetWindowId`). `null` until
3591 - * then.
3592 - */
3593 - get windowId() {
3594 - return _wpdWindowId;
3595 - },
3596 - /**
3597 - * Resolve once `windowId` is populated by the first handshake.
3598 - * Resolves immediately if already known. Never rejects — guard
3599 - * with `isParentReachable()` first.
3600 - */
3601 - whenWindowId: function () {
3602 - if ( _wpdWindowId !== null ) {
3603 - return Promise.resolve( _wpdWindowId );
3604 - }
3605 - return new Promise( function ( resolve ) {
3606 - _wpdWindowIdWaiters.push( resolve );
3607 - } );
3608 - },
3609 - /**
3610 - * Whether the parent frame is same-origin and reachable. All
3611 - * bridge messages hard-filter on origin — a cross-origin
3612 - * parent silently drops everything we post. Use this predicate
3613 - * to fail fast instead of debugging vanishing messages.
3614 - */
3615 - isParentReachable: function () {
3616 - if ( ! window.parent || window.parent === window ) {
3617 - return false;
3618 - }
3619 - try {
3620 - /* Cross-origin parents throw on `.location.origin`
3621 - * access; same-origin parents return a string we can
3622 - * compare to our own origin. */
3623 - return window.parent.location.origin === _wpdParentOrigin;
3624 - } catch ( _err ) {
3625 - return false;
3626 - }
3627 - }
3628 - };
3629 -
3630 - if ( ! window.wp ) { window.wp = {}; }
3631 - if ( ! window.wp.os ) { window.wp.os = {}; }
3632 - window.wp.os.iframe = iframeApi;
3633 -
3634 - /* Unified window-channel API. Mirror of the equivalent block
3635 - * in `assets/js/iframe-bridge.js` — keep both in sync. The
3636 - * parent shell posts `os-window-send` on
3637 - * `Window.send( channel, payload )`; iframe-side handlers
3638 - * register via `wp.os.on( channel, cb )`. Sending the
3639 - * other way (`wp.os.send`) posts up to the parent where
3640 - * `Window.on( channel, cb )` subscribers fire. */
3641 - if ( typeof window.wp.os.send !== 'function' ) {
3642 - window.wp.os.send = function ( channel, payload ) {
3643 - if ( typeof channel !== 'string' || channel === '' ) {
3644 - return;
3645 - }
3646 - try {
3647 - window.parent.postMessage( {
3648 - type: 'os-window-publish',
3649 - channel: channel,
3650 - payload: payload
3651 - }, _wpdParentOrigin );
3652 - } catch ( _err ) { /* parent gone */ }
3653 - };
3654 - }
3655 - if ( typeof window.wp.os.on !== 'function' ) {
3656 - window.wp.os.on = function ( channel, cb ) {
3657 - if ( typeof channel !== 'string' || channel === '' || typeof cb !== 'function' ) {
3658 - return function () {};
3659 - }
3660 - var bucket = _wpdChannelSubs[ channel ];
3661 - if ( ! bucket ) {
3662 - bucket = [];
3663 - _wpdChannelSubs[ channel ] = bucket;
3664 - }
3665 - bucket.push( cb );
3666 - return function () {
3667 - var i = bucket.indexOf( cb );
3668 - if ( i >= 0 ) {
3669 - bucket.splice( i, 1 );
3670 - }
3671 - };
3672 - };
3673 - }
3674 -
3675 - /* -----------------------------------------------------------------
3676 - * Stale-nonce recovery after a session-expiry re-login.
3677 - *
3678 - * When the user's session expires while a chromeless window is
3679 - * open, this iframe does NOT show core's `wp-auth-check` login
3680 - * modal — `openstation_chromeless_suppress_auth_check()` keeps
3681 - * the modal assets out of chromeless requests so the parent
3682 - * shell owns the single prompt for the whole desktop. Detection
3683 - * still works without the modal JS: core attaches the
3684 - * `wp-auth-check` boolean to every heartbeat response
3685 - * server-side, and this iframe's own heartbeat keeps ticking.
3686 - *
3687 - * After re-auth the auth cookie is fresh — but every per-page
3688 - * nonce cached in JS globals (`_wpUpdatesSettings.ajax_nonce`,
3689 - * `commonL10n.nonce`, Gutenberg's `wpApiSettings.nonce`, etc.)
3690 - * was minted under the OLD session and is now rejected by
3691 - * `check_ajax_referer`. WP reports that as "Cookie check
3692 - * failed" on the next plugin Install / Activate / Update click,
3693 - * which is misleading: the cookie is fine; the nonce is stale.
3694 - *
3695 - * Fix: watch jQuery's `heartbeat-tick`. If we ever see
3696 - * `wp-auth-check: false` and then later see the same field flip
3697 - * back to `true`, the user re-authed mid-session and every
3698 - * cached nonce in this iframe is stale — reload so they
3699 - * regenerate from the fresh session. The parent is nudged
3700 - * first (`os-reauth-detected`) so its own recovery
3701 - * (`src/auth-recovery/index.ts`: in-place nonce refresh + a
3702 - * reload sweep over sibling iframes that haven't ticked yet)
3703 - * starts immediately instead of waiting for the parent's
3704 - * heartbeat schedule.
3705 - *
3706 - * If jQuery never loads on this page (rare — most admin screens
3707 - * pull it for heartbeat already), this block is a no-op.
3708 - * ----------------------------------------------------------------- */
3709 - ( function _wpdInstallAuthCheckRecovery() {
3710 - var attached = false;
3711 - var sawLoggedOut = false;
3712 - function attach() {
3713 - if ( attached || ! window.jQuery ) {
3714 - return;
3715 - }
3716 - attached = true;
3717 - window.jQuery( document ).on( 'heartbeat-tick.osAuthRecover', function ( ev, data ) {
3718 - if ( ! data || typeof data !== 'object' || ! ( 'wp-auth-check' in data ) ) {
3719 - return;
3720 - }
3721 - if ( data[ 'wp-auth-check' ] === false ) {
3722 - sawLoggedOut = true;
3723 - return;
3724 - }
3725 - if ( sawLoggedOut && data[ 'wp-auth-check' ] === true ) {
3726 - sawLoggedOut = false;
3727 - // Tell the parent shell BEFORE we reload so it
3728 - // doesn't have to wait for its own heartbeat
3729 - // tick (up to 60s on an idle shell) to discover
3730 - // the cookie is fresh. Parent runs its full
3731 - // recovery path on receipt — overlay teardown,
3732 - // iframe reload sweep, then a hard reload.
3733 - try {
3734 - if ( window.parent && window.parent !== window ) {
3735 - window.parent.postMessage(
3736 - { type: 'os-reauth-detected' },
3737 - window.location.origin
3738 - );
3739 - }
3740 - } catch ( _err ) { /* parent gone */ }
3741 - try { window.location.reload(); } catch ( _err ) { /* swallow */ }
3742 - }
3743 - } );
3744 - }
3745 - attach();
3746 - if ( document.readyState === 'loading' ) {
3747 - document.addEventListener( 'DOMContentLoaded', attach, { once: true } );
3748 - }
3749 - window.addEventListener( 'load', attach, { once: true } );
3750 - } )();
3751 -
3752 - /* -----------------------------------------------------------------
3753 - * Shiny-update watcher (GH#296).
3754 - *
3755 - * Core's updates.js applies plugin/theme updates and deletes over
3756 - * AJAX — no navigation, so the load-time payload emit above never
3757 - * re-fires and the shell's update notifiers (admin-bar circle-arrows
3758 - * count, dock Plugins badge) keep showing the pre-update numbers
3759 - * until a hard refresh. Watch the jQuery events updates.js triggers
3760 - * on `document` after each job and nudge the shell to spend one
3761 - * `refreshMenu()` probe, whose payload carries fresh counts.
3762 - *
3763 - * Error events are included deliberately: `wp_ajax_update_plugin`
3764 - * calls `wp_update_plugins()` up front, which can mutate the
3765 - * update transient even when the upgrade itself fails.
3766 - *
3767 - * When updates.js is processing a queue (bulk-selected shiny
3768 - * updates), per-job events fire while later jobs are still
3769 - * pending — skip those and let the final job's event send the one
3770 - * nudge. The shell debounces on its side too, so this is purely
3771 - * an optimization, not a correctness gate.
3772 - *
3773 - * If jQuery never loads on this page this block is a no-op — and
3774 - * so is updates.js, which requires it.
3775 - * ----------------------------------------------------------------- */
3776 - ( function _wpdInstallShinyUpdateWatcher() {
3777 - var attached = false;
3778 - function notify() {
3779 - try {
3780 - var queue = window.wp && window.wp.updates && window.wp.updates.queue;
3781 - if ( queue && queue.length > 0 ) {
3782 - return;
3783 - }
3784 - } catch ( _err ) { /* queue introspection is best-effort */ }
3785 - try {
3786 - var shell = window.top || window.parent;
3787 - if ( shell && shell !== window ) {
3788 - shell.postMessage(
3789 - { type: 'os-updates-changed' },
3790 - window.location.origin
3791 - );
3792 - }
3793 - } catch ( _err ) { /* shell gone or cross-origin */ }
3794 - }
3795 - function notifyPluginInstall() {
3796 - // `wp-plugin-install-success` fires after an AJAX install on
3797 - // plugin-install.php with no page navigation. The PHP
3798 - // `upgrader_process_complete` hook records the change correctly,
3799 - // but `openstation_content_changes_emit_footer` only runs on
3800 - // chromeless page requests — admin-ajax.php is not in the
3801 - // chromeless allowlist, so there's no in-band emit from that
3802 - // request. The Heartbeat buffer will eventually deliver it, but
3803 - // posting directly here lets the Installed tab refresh
3804 - // immediately. The later Heartbeat tick will produce a second
3805 - // broadcast; consumers handle no-op refreshes gracefully.
3806 - try {
3807 - var shell = window.top || window.parent;
3808 - if ( shell && shell !== window ) {
3809 - shell.postMessage(
3810 - {
3811 - type: 'os-broadcast',
3812 - topic: 'os.plugin.changed',
3813 - payload: { source: 'chromeless-bridge', action: 'install' }
3814 - },
3815 - window.location.origin
3816 - );
3817 - }
3818 - } catch ( _err ) { /* shell gone or cross-origin */ }
3819 - }
3820 - function attach() {
3821 - if ( attached || ! window.jQuery ) {
3822 - return;
3823 - }
3824 - attached = true;
3825 - window.jQuery( document ).on(
3826 - [
3827 - 'wp-plugin-update-success.osUpdates',
3828 - 'wp-plugin-update-error.osUpdates',
3829 - 'wp-plugin-delete-success.osUpdates',
3830 - 'wp-theme-update-success.osUpdates',
3831 - 'wp-theme-update-error.osUpdates',
3832 - 'wp-theme-delete-success.osUpdates'
3833 - ].join( ' ' ),
3834 - notify
3835 - );
3836 - window.jQuery( document ).on( 'wp-plugin-install-success.osUpdates', notifyPluginInstall );
3837 - }
3838 - attach();
3839 - if ( document.readyState === 'loading' ) {
3840 - document.addEventListener( 'DOMContentLoaded', attach, { once: true } );
3841 - }
3842 - window.addEventListener( 'load', attach, { once: true } );
3843 - } )();
3844 -
3845 - /*
3846 - * Bridge-ready signal. Every listener installed by this script
3847 - * is now wired; let the parent shell know so it can fire
3848 - * `HOOKS.IFRAME_READY` and re-arm any connection handshakes
3849 - * (`src/connection/index.ts#onIframeReady`) that arrived before
3850 - * we were listening. Without this, every consumer of
3851 - * `HOOKS.IFRAME_READY` (devtools replay, connection rearm)
3852 - * stays silent for the lifetime of the iframe — documented
3853 - * surface that never actually fires.
3854 - *
3855 - * Posted to the parent's own origin only. Wrapped in try/catch
3856 - * because cross-origin parents (top-level admin opened outside
3857 - * the shell) would throw on the postMessage and we don't want a
3858 - * single failed dispatch to wedge anything else above.
3859 - */
3860 - try {
3861 - if ( window.parent && window.parent !== window ) {
3862 - window.parent.postMessage(
3863 - { type: 'os-ready' },
3864 - window.location.origin
3865 - );
3866 - }
3867 - } catch ( _err ) { /* parent gone or cross-origin */ }
3868 -} )();
3869 -JS;
3870 -
3871 442 // On pages that don't carry a full payload, ship the lightweight
3872 443 // menu signature so the shell can detect an off-allowlist menu
3873 444 // change (e.g. a CPT registered via a settings tool) and refresh
3874 445 // only then. The full payload already embeds its own `menuSig`, so
@@ -3925,17 +496,51 @@
3925 496 if ( ! $soft_reload_json ) {
3926 497 $soft_reload_json = '[]';
3927 498 }
3928 499
3929 - // Substitute the server-built menu payload into the bridge
3930 - // script. `wp_json_encode` guarantees safe JSON output — no need
3931 - // for an additional escape pass. When the page isn't on our
3932 - // menu-altering allowlist the placeholder resolves to `null` and
3933 - // the bridge skips the postMessage.
3934 - $js = str_replace( '/*__OPENSTATION_MENU_PAYLOAD__*/', $menu_payload_json, $js );
3935 - $js = str_replace( '/*__OPENSTATION_MENU_SIG__*/', $menu_sig_json, $js );
3936 - $js = str_replace( '/*__OPENSTATION_CONTENT_IDENTITY__*/', $content_identity_json, $js );
3937 - $js = str_replace( '/*__OPENSTATION_SOFT_RELOAD_EXTRAS__*/', $soft_reload_json, $js );
500 + // Per-request data for the bridge bundle.
501 + //
502 + // This used to be a `str_replace()` pass over a nowdoc holding the
503 + // whole bridge, printed inline — roughly 125 KB of unminified
504 + // JavaScript in the HTML of every window, comments included. The
505 + // document is the one asset no cache can help with, so that cost
506 + // was paid in full on every window open. The code now lives in
507 + // `src/chromeless-bridge.js` and builds to a bundle that is
508 + // fetched once and served from cache (browser, and the shared
509 + // service-worker cache) for every later window; only these four
510 + // values still have to vary per request.
511 + //
512 + // `wp_json_encode` already guarantees safe JSON output, so the
513 + // values are interpolated as-is — the same guarantee the
514 + // `str_replace` pass relied on. Keys are underscore-prefixed to
515 + // mark them as a private contract with the bundle rather than a
516 + // public API; `src/chromeless-bridge.js` reads exactly these four.
517 + $data = sprintf(
518 + 'window.__osChromelessData = { _menuPayload: %s, _menuSig: %s, _identity: %s, _softReload: %s };',
519 + $menu_payload_json,
520 + $menu_sig_json,
521 + $content_identity_json,
522 + $soft_reload_json
523 + );
3938 524
3939 - wp_print_inline_script_tag( $js );
525 + // `in_footer` + a `before` inline block reproduces exactly what the
526 + // old inline print did: the data is defined, then the bridge runs,
527 + // both at the same point in the document. No `defer` / `async` —
528 + // the bridge expects to execute synchronously here, and deferring
529 + // it would move it after the page's own footer scripts.
530 + // Defensive re-registration. `WP_Dependencies::enqueue()` silently
531 + // no-ops on a handle that isn't registered, and plugins that
532 + // rebuild `WP_Scripts` wholesale are a real, documented shape —
533 + // see `includes/render/asset-guard.php` for the same class of
534 + // conflict. The bridge is the one script a window genuinely cannot
535 + // do without: losing it costs the window its title, its links, its
536 + // activity ring and its refresh signalling. `wp_register_script()`
537 + // no-ops when the handle is already there, so this costs nothing
538 + // on the normal path.
539 + if ( ! wp_script_is( 'os-chromeless-bridge', 'registered' ) ) {
540 + openstation_register_assets();
541 + }
542 +
543 + wp_enqueue_script( 'os-chromeless-bridge' );
544 + wp_add_inline_script( 'os-chromeless-bridge', $data, 'before' );
3940 545 }
3941 546 add_action( 'admin_footer', 'openstation_chromeless_bridge_script' );