PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.12
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.12
1.1.12 1.1.11 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 All 36 releases
← All changes | includes/core/payload.php +815 -119 1.1.2 → 1.1.12 View file →
@@ -348,8 +348,11 @@
348 348 }
349 349 $sub_entry = array(
350 350 'title' => $sub_title,
351 351 'url' => $sub_url,
352 + // The registered slug, kept for the window-tab merge
353 + // below and stripped again before the payload ships.
354 + 'slug' => (string) $row['slug'],
352 355 );
353 356 if ( $row['external'] ) {
354 357 // Consumers that route a URL into a window skip these;
355 358 // the ones that can hand a link to the browser mark
@@ -399,8 +402,47 @@
399 402 if ( $parent_is_container && $url === $parent_url ) {
400 403 continue;
401 404 }
402 405
406 + // A native window in charge of this menu owns its rows too:
407 + // whatever it offers as a tab, the dock offers as a row, same
408 + // labels and same order, each one tagged with the tab it
409 + // opens. The first tab IS the menu's own page, so it becomes
410 + // the self-label rather than a second row for the tile's own
411 + // destination. See `App::menu()`.
412 + $window_tabs = openstation_app_menu_tabs( $identity_slug );
413 + if ( $window_tabs ) {
414 + $self_label = $window_tabs[0]['label'];
415 + $claimed = array();
416 + foreach ( $window_tabs as $tab ) {
417 + if ( '' !== $tab['page'] ) {
418 + $claimed[] = $tab['page'];
419 + }
420 + }
421 + // A page this window has no tab for is still a page: a
422 + // plugin's screen registered under this menu, a taxonomy
423 + // someone added. Dropping those would make them
424 + // unreachable from the dock, so they follow the window's
425 + // own rows rather than being replaced by them.
426 + $kept = array();
427 + foreach ( $sub_items as $sub_entry ) {
428 + if ( ! in_array( $sub_entry['slug'], $claimed, true ) ) {
429 + $kept[] = $sub_entry;
430 + }
431 + }
432 + $sub_items = array();
433 + foreach ( array_slice( $window_tabs, 1 ) as $tab ) {
434 + $sub_items[] = array(
435 + 'title' => $tab['label'],
436 + 'url' => add_query_arg( 'os_tab', $tab['id'], $url ),
437 + );
438 + }
439 + $sub_items = array_merge( $sub_items, $kept );
440 + }
441 + foreach ( $sub_items as $i => $sub_entry ) {
442 + unset( $sub_items[ $i ]['slug'] );
443 + }
444 +
403 445 $dock_item = array(
404 446 'id' => sanitize_key( $item[5] ?? $item[2] ),
405 447 'title' => $title,
406 448 'icon' => $icon,
@@ -454,10 +496,12 @@
454 496 * URLs) would therefore fill the dock with tiles that can only ever
455 497 * escape to a browser tab, which breaks the shell's navigation model.
456 498 * Those entries are dropped from the payload instead.
457 499 *
458 - * Both `admin_url()` and `home_url()` hosts count as ours: a site can
459 - * run its admin on a different domain than its front end.
500 + * The menu's own admin (`openstation_menu_admin_url()`), `admin_url()`
501 + * and `home_url()` hosts all count as ours: a site can run its admin on
502 + * a different domain than its front end, and the network admin lives on
503 + * the network's own.
460 504 *
461 505 * @param string $url Absolute URL, as returned by `openstation_menu_item_url()`.
462 506 * @return bool True when the URL is off-site.
463 507 */
@@ -466,9 +510,9 @@
466 510 $external = false;
467 511
468 512 if ( $host ) {
469 513 $ours = array();
470 - foreach ( array( admin_url(), home_url() ) as $known ) {
514 + foreach ( array( openstation_menu_admin_url(), admin_url(), home_url() ) as $known ) {
471 515 $known_host = wp_parse_url( $known, PHP_URL_HOST );
472 516 if ( $known_host ) {
473 517 $ours[] = strtolower( $known_host );
474 518 }
@@ -728,12 +772,13 @@
728 772
729 773 /**
730 774 * Filters whether a dock item supports multiple open windows.
731 775 *
732 - * Return true to let the user open more than one window of this page.
733 - * A "+" affordance appears on the dock icon and a "Open another" action
734 - * becomes available in the window's title-bar menu. Singletons (false)
735 - * always focus the existing window when re-opened.
776 + * Return true to advertise this page as multi-capable: an instance
777 + * rail appears under the dock icon and an "Open another" action
778 + * becomes available in the window's title-bar menu. It does not gate
779 + * the submenu, which opens a window of its own on every pick either
780 + * way; a tile click focuses the menu's open window.
736 781 *
737 782 * @param bool $multi Whether this page is multi-capable.
738 783 * @param string $menu_slug The menu slug (e.g. `edit.php?post_type=page`).
739 784 */
@@ -817,8 +862,19 @@
817 862 'link-manager.php', // Link manager (legacy)
818 863 'update-core.php', // Dashboard > Updates
819 864 );
820 865
866 + // The two top-level network menus the site admin has no filename
867 + // for: without them, Sites and Settings sat in the apps zone while
868 + // Dashboard, Users, Themes and Plugins — whose filenames the site
869 + // admin shares — grouped correctly. Gated on the context, since
870 + // `settings.php` is plausible enough as a plugin's own top-level
871 + // slug that claiming it everywhere would misfile it.
872 + if ( is_network_admin() ) {
873 + $core_files[] = 'sites.php';
874 + $core_files[] = 'settings.php';
875 + }
876 +
821 877 return in_array( $base, $core_files, true );
822 878 }
823 879
824 880 /**
@@ -1583,11 +1639,16 @@
1583 1639 }
1584 1640
1585 1641 $dock = array_merge( $core, $plugin );
1586 1642
1643 + // One collector call feeds both halves: the slim entry list and
1644 + // the handle-keyed script data the shell joins them with.
1645 + $native_windows = openstation_collect_native_windows_payload();
1646 +
1587 1647 $payload = array(
1588 - 'dockItems' => $dock,
1589 - 'nativeWindows' => openstation_build_native_windows_payload(),
1648 + 'dockItems' => $dock,
1649 + 'nativeWindows' => $native_windows['windows'],
1650 + 'nativeWindowScriptData' => $native_windows['scriptData'],
1590 1651 );
1591 1652
1592 1653 // Optional per-surface payload builders — each module ships a
1593 1654 // zero-arg `openstation_build_*_payload()`; modules that aren't
@@ -1645,8 +1706,15 @@
1645 1706 'url' => network_admin_url( 'update-core.php' ),
1646 1707 );
1647 1708 }
1648 1709
1710 + // The site switcher's rows: on a network, the instances this shell
1711 + // may switch to (`openstation_multisite_payload()`), null elsewhere.
1712 + // The Network app spends a menu refresh after every action that
1713 + // changes them (add, remove, join, leave, sync), so the row above
1714 + // overview's desktop tiles follows the registry without a reload.
1715 + $payload['multisite'] = openstation_multisite_payload();
1716 +
1649 1717 // A cheap structural fingerprint of the admin menu the shell uses to
1650 1718 // decide whether a live refresh is warranted. Shipped in every full
1651 1719 // payload so the shell can seed / update its last-known signature
1652 1720 // without recomputing it client-side (which would risk drift from
@@ -1653,8 +1721,13 @@
1653 1721 // the server's capability-gated view). See
1654 1722 // openstation_menu_signature().
1655 1723 $payload['menuSig'] = openstation_menu_signature();
1656 1724
1725 + // Each script dependency's payload once, entries carry handles.
1726 + $script_dep_payloads = array();
1727 + $payload = openstation_compact_script_deps( $payload, $script_dep_payloads );
1728 + $payload['scriptDepPayloads'] = (object) $script_dep_payloads;
1729 +
1657 1730 return $payload;
1658 1731 }
1659 1732
1660 1733 /**
@@ -1729,8 +1802,247 @@
1729 1802 return md5( implode( "\n", $parts ) );
1730 1803 }
1731 1804
1732 1805 /**
1806 + * A handle's dependency closure, in load order.
1807 + *
1808 + * Post-order depth-first: a handle is emitted only after everything it
1809 + * declares, which is the order `WP_Scripts::do_item()` would have
1810 + * printed them in. A handle is marked visited *before* its own
1811 + * dependencies are walked, so a dependency cycle unwinds instead of
1812 + * recursing forever, and an unregistered handle is skipped rather than
1813 + * being fatal — it contributes nothing and stops nothing.
1814 + *
1815 + * **Deliberately not `WP_Dependencies::all_deps()`.** Three reasons,
1816 + * each of which has bitten this codebase:
1817 + *
1818 + * 1. `WP_Scripts::all_deps()` applies `print_scripts_array` to its
1819 + * result whenever `$recursion` is falsy. That filter is where the
1820 + * chromeless palette trim and the asset guard live, so resolving a
1821 + * payload through it would run a print-time trim across a dependency
1822 + * list and let the guard splice this plugin's own bundles into it.
1823 + * Called from inside one of those filters it is an infinite loop.
1824 + *
1825 + * 2. Passing `$recursion = true` silences that filter but changes the
1826 + * contract: the first handle that fails aborts the entire call
1827 + * (`return false`), abandoning every handle after it in the list. The
1828 + * caller is left with a `$to_do` that is a truncated prefix of the real
1829 + * closure and indistinguishable from a complete one — a silent, partial
1830 + * answer conditional on unrelated registrations elsewhere on the page.
1831 + * A lazily-delivered bundle resolved that way loses packages it
1832 + * declared and throws on an undefined global at mount, which is the
1833 + * exact bug this whole mechanism exists to prevent.
1834 + *
1835 + * 3. `all_deps()` reports missing dependencies through
1836 + * `_doing_it_wrong()`. This is read-only analysis; the real print pass
1837 + * raises those anyway, and raising them twice turns someone else's
1838 + * pre-existing warning into our noise.
1839 + *
1840 + * O(V+E) over the graph, allocates one set, and clones nothing.
1841 + *
1842 + * @param WP_Dependencies $dependencies The scripts or styles registry.
1843 + * @param string[] $handles Roots to walk.
1844 + * @return string[] Registered handles, dependencies before dependents.
1845 + */
1846 +function openstation_script_dependency_closure( $dependencies, $handles ) {
1847 + $seen = array();
1848 + $out = array();
1849 + openstation_collect_script_dependency_closure( $dependencies, (array) $handles, $seen, $out );
1850 +
1851 + return $out;
1852 +}
1853 +
1854 +/**
1855 + * Recursive half of {@see openstation_script_dependency_closure()}.
1856 + *
1857 + * @param WP_Dependencies $dependencies The scripts or styles registry.
1858 + * @param string[] $handles Handles to walk.
1859 + * @param array $seen Handle => true, by reference.
1860 + * @param string[] $out Ordered result, by reference.
1861 + */
1862 +function openstation_collect_script_dependency_closure( $dependencies, $handles, &$seen, &$out ) {
1863 + foreach ( (array) $handles as $handle ) {
1864 + if ( isset( $seen[ $handle ] ) ) {
1865 + continue;
1866 + }
1867 + // Marked BEFORE recursing, so a cycle meets itself as visited
1868 + // and unwinds rather than recursing forever.
1869 + $seen[ $handle ] = true;
1870 + if ( ! isset( $dependencies->registered[ $handle ] ) ) {
1871 + continue;
1872 + }
1873 + openstation_collect_script_dependency_closure(
1874 + $dependencies,
1875 + $dependencies->registered[ $handle ]->deps,
1876 + $seen,
1877 + $out
1878 + );
1879 + $out[] = $handle;
1880 + }
1881 +}
1882 +
1883 +/**
1884 + * Resolve a handle's dependency closure, in load order.
1885 + *
1886 + * **Why a lazily-delivered handle needs this at all.** WordPress
1887 + * normally resolves a script's dependencies when it enqueues it — the
1888 + * packages a bundle declares are on the page before its own body runs.
1889 + * A handle that is only ever delivered lazily never goes through that:
1890 + * `loadVendorScript()` injects one URL, and a bundle declaring
1891 + * `wp-api-fetch` found `wp.apiFetch` undefined at mount.
1892 + *
1893 + * That used to work by accident. Core's ⌘K palette was enqueued on
1894 + * every admin page and its closure is the whole Gutenberg runtime, so
1895 + * `wp.apiFetch`, `wp.element` and friends happened to be globals.
1896 + * Deferring the palette took the accident away and left the contract
1897 + * exposed — see `docs/migration-wp-package-globals.md`.
1898 + *
1899 + * The closure comes from {@see openstation_script_dependency_closure()}
1900 + * rather than `WP_Dependencies::all_deps()`; that function's docblock
1901 + * records why, and the short version is that `all_deps()` answers a
1902 + * question like this one with a silently truncated list. The handle
1903 + * itself is excluded — the caller loads it separately, after these.
1904 + *
1905 + * @param string $handle Script handle.
1906 + * @return array<int,array<string,mixed>> Ordered dependency payloads.
1907 + */
1908 +function openstation_resolve_script_dependencies( $handle ) {
1909 + $handle = (string) $handle;
1910 + $wp_scripts = wp_scripts();
1911 + if ( '' === $handle || ! $wp_scripts || ! isset( $wp_scripts->registered[ $handle ] ) ) {
1912 + return array();
1913 + }
1914 + $deps = $wp_scripts->registered[ $handle ]->deps;
1915 + if ( empty( $deps ) ) {
1916 + return array();
1917 + }
1918 +
1919 + $out = array();
1920 + foreach ( openstation_script_dependency_closure( $wp_scripts, $deps ) as $dep_handle ) {
1921 + if ( $dep_handle === $handle ) {
1922 + continue;
1923 + }
1924 + $payload = openstation_resolve_script_payload( $dep_handle );
1925 + // An alias (no `src`) stays in the list when it carries inline
1926 + // data — that data is the whole reason it was declared, and a
1927 + // plugin's config blob commonly rides one. Nothing to fetch
1928 + // AND nothing to run is the only thing dropped.
1929 + if ( '' === $payload['url']
1930 + && empty( $payload['before'] )
1931 + && empty( $payload['after'] )
1932 + && empty( $payload['l10n'] ) ) {
1933 + continue;
1934 + }
1935 + // The handle rides along because the shell needs it to decide
1936 + // whether the page already has this package. A URL is not
1937 + // enough: with Core's script concatenation on — the wp-admin
1938 + // default — every package below `wp-includes/js/` is served
1939 + // from one `load-scripts.php` blob and has no `<script src>`
1940 + // of its own to match against. Re-running `wp-hooks` because
1941 + // we could not see it replaces `window.wp.hooks`, and every
1942 + // subscriber registered at boot goes deaf. See
1943 + // `src/script-presence.ts`.
1944 + $payload['handle'] = (string) $dep_handle;
1945 + $out[] = $payload;
1946 + }
1947 + return $out;
1948 +}
1949 +
1950 +/**
1951 + * Move every entry's `scriptDeps` payloads into one map.
1952 + *
1953 + * {@see openstation_resolve_script_dependencies()} returns the full
1954 + * payload of each dependency (URL, l10n, before/after), and ~20 entry
1955 + * builders call it. A dependency shared by N entries was therefore
1956 + * serialized N times: one plugin's 5.5 KB localized object became
1957 + * ~400 KB of a 489 KB `openStationConfig` (GH#892). This replaces each
1958 + * entry's `scriptDeps` list with its handles and puts each handle's
1959 + * payload in `$map` once. The shell resolves the handles back before
1960 + * any consumer reads them; see `src/script-dep-payloads.ts`.
1961 + *
1962 + * ENTRY DEPTH ONLY. `$payload` is a payload whose top-level values are
1963 + * entry lists (`serverWidgets`, `serverCommandScripts`, ...), and only
1964 + * a `scriptDeps` sitting directly on one of those entries is touched.
1965 + * The key is the shell's there. Deeper down it is a plugin's metadata
1966 + * (`settings => [ 'scriptDeps' => ... ]`), and rewriting it would hoist
1967 + * foreign data into the map first-wins and hydrate every real
1968 + * dependency of that handle to it. Scoping by depth rather than by a
1969 + * list of keys means a new builder is covered without an edit here.
1970 + * It also leaves every other branch of the payload unassigned, so
1971 + * PHP's copy-on-write never has to copy them.
1972 + *
1973 + * Every string left in a compacted list has an entry in `$map`: a bare
1974 + * handle a builder (or a filter on one) emitted is resolved here, by
1975 + * the same rule as {@see openstation_resolve_script_dependencies()}.
1976 + * A handle with nothing to fetch and nothing to run is dropped, as it
1977 + * is there. The client can then treat a string with no map entry as a
1978 + * payload from somewhere else, not a dependency this side dropped.
1979 + *
1980 + * Runs on the finished payload, so every builder, and every filter on
1981 + * a builder's output, still sees the full shape.
1982 + *
1983 + * @param array $payload Payload whose top-level values are entry lists.
1984 + * @param array $map Handle => dependency payload, filled in place.
1985 + * @return array The payload with each entry's `scriptDeps` reduced to handles.
1986 + */
1987 +function openstation_compact_script_deps( $payload, array &$map ) {
1988 + if ( ! is_array( $payload ) ) {
1989 + return $payload;
1990 + }
1991 + foreach ( $payload as $list_key => $entries ) {
1992 + if ( ! is_array( $entries ) ) {
1993 + continue;
1994 + }
1995 + foreach ( $entries as $entry_key => $entry ) {
1996 + if ( ! is_array( $entry ) || ! isset( $entry['scriptDeps'] ) || ! is_array( $entry['scriptDeps'] ) ) {
1997 + continue;
1998 + }
1999 + $payload[ $list_key ][ $entry_key ]['scriptDeps'] = openstation_compact_script_dep_list( $entry['scriptDeps'], $map );
2000 + }
2001 + }
2002 + return $payload;
2003 +}
2004 +
2005 +/**
2006 + * One entry's `scriptDeps` list, reduced to handles.
2007 + *
2008 + * @param array $deps Dependency payloads and/or bare handles.
2009 + * @param array $map Handle => dependency payload, filled in place.
2010 + * @return array Handles, in order; anything unkeyable passes through.
2011 + */
2012 +function openstation_compact_script_dep_list( array $deps, array &$map ) {
2013 + $handles = array();
2014 + foreach ( $deps as $dep ) {
2015 + if ( is_string( $dep ) && '' !== $dep ) {
2016 + if ( ! isset( $map[ $dep ] ) ) {
2017 + $payload = openstation_resolve_script_payload( $dep );
2018 + if ( '' === $payload['url']
2019 + && empty( $payload['before'] )
2020 + && empty( $payload['after'] )
2021 + && empty( $payload['l10n'] ) ) {
2022 + continue;
2023 + }
2024 + $payload['handle'] = $dep;
2025 + $map[ $dep ] = $payload;
2026 + }
2027 + $handles[] = $dep;
2028 + continue;
2029 + }
2030 + if ( is_array( $dep ) && isset( $dep['handle'] ) && '' !== (string) $dep['handle'] ) {
2031 + $handle = (string) $dep['handle'];
2032 + if ( ! isset( $map[ $handle ] ) ) {
2033 + $map[ $handle ] = $dep;
2034 + }
2035 + $handles[] = $handle;
2036 + continue;
2037 + }
2038 + // A handle-less payload has nothing to key it by.
2039 + $handles[] = $dep;
2040 + }
2041 + return $handles;
2042 +}
2043 +
2044 +/**
1733 2045 * Resolve a registered WP script handle into the full payload the
1734 2046 * shell needs to lazy-load it without going through `wp_print_scripts()`.
1735 2047 *
1736 2048 * Returns:
@@ -1754,10 +2066,14 @@
1754 2066 * around the lazy `<script src>` in the same order
1755 2067 * `WP_Scripts::do_item()` would have used.
1756 2068 *
1757 2069 * Returns an empty payload (`array( 'url' => '' )`) when the handle
1758 - * is unregistered or has no source — callers treat that as "no
1759 - * script to load."
2070 + * is unregistered. A registered handle with no source — an alias
2071 + * carrying only inline data — also comes back with an empty `url`,
2072 + * but its `before` / `after` / `l10n` are kept: callers that load a
2073 + * bundle treat an empty `url` as "nothing to fetch", and the
2074 + * dependency walk ({@see openstation_resolve_script_dependencies()})
2075 + * still replays what the alias would have printed.
1760 2076 *
1761 2077 * Shared between `openstation_register_window()` and
1762 2078 * `openstation_register_widget()` (and every other registration that
1763 2079 * relies on lazy script loading in the shell) because all of them
@@ -1785,20 +2101,31 @@
1785 2101 return $empty;
1786 2102 }
1787 2103 $registered = $wp_scripts->registered[ $handle ];
1788 2104 $src = is_string( $registered->src ) ? $registered->src : '';
1789 - if ( '' === $src ) {
1790 - return $empty;
1791 - }
1792 2105
1793 - // Normalize relative paths + attach cache-bust ver.
1794 - $resolved = $src;
1795 - if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) {
1796 - $resolved = site_url( $resolved );
2106 + // A handle with no `src` is an ALIAS — WordPress's supported way
2107 + // to ship inline-only JavaScript (`wp_register_script( $h, false )`
2108 + // plus `wp_add_inline_script()`), and a common home for a plugin's
2109 + // config blob: registering it as a *dependency* of every bundle is
2110 + // what guarantees the config runs first, whatever the enqueue
2111 + // order. `WP_Scripts::do_item()` prints an alias's localized data
2112 + // and its before/after snippets and returns before the `<script
2113 + // src>` it does not have. The payload mirrors that: `url` stays
2114 + // empty (there is nothing to fetch) and the inline data is kept,
2115 + // so a dependency walk can replay it. Translations are not: Core
2116 + // only prints those for a handle it printed a tag for.
2117 + $resolved = '';
2118 + if ( '' !== $src ) {
2119 + // Normalize relative paths + attach cache-bust ver.
2120 + $resolved = $src;
2121 + if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) {
2122 + $resolved = site_url( $resolved );
2123 + }
2124 + if ( ! empty( $registered->ver ) ) {
2125 + $resolved = add_query_arg( 'ver', $registered->ver, $resolved );
2126 + }
1797 2127 }
1798 - if ( ! empty( $registered->ver ) ) {
1799 - $resolved = add_query_arg( 'ver', $registered->ver, $resolved );
1800 - }
1801 2128
1802 2129 // Harvest `extra` data the lazy-load path would otherwise drop.
1803 2130 $before = array();
1804 2131 $after = array();
@@ -1833,9 +2160,9 @@
1833 2160 // `wp.i18n.setLocaleData( JSON, 'domain' )` snippet that the print
1834 2161 // pipeline emits before the script body. `print_translations(
1835 2162 // $handle, false )` returns the snippet without echoing.
1836 2163 $translations = '';
1837 - if ( method_exists( $wp_scripts, 'print_translations' ) ) {
2164 + if ( '' !== $resolved && method_exists( $wp_scripts, 'print_translations' ) ) {
1838 2165 $captured = $wp_scripts->print_translations( $handle, false );
1839 2166 if ( is_string( $captured ) ) {
1840 2167 $translations = $captured;
1841 2168 }
@@ -1922,8 +2249,192 @@
1922 2249 );
1923 2250 }
1924 2251
1925 2252 /**
2253 + * Build the deferred command-palette asset manifest.
2254 + *
2255 + * `wp_enqueue_command_palette_assets()` (WP 6.9+) enqueues
2256 + * `wp-commands` + `wp-core-commands` and attaches the inline
2257 + * `wp.coreCommands.initializeCommandPalette( … )` call that seeds the
2258 + * `core/commands` store. Its transitive dependency chain is the whole
2259 + * Gutenberg runtime — `wp-block-editor`, `wp-components`, React,
2260 + * `wp-core-data`, some forty bundles, ~800 KB gzipped — which the
2261 + * shell used to pay on EVERY boot so that the ⌘K palette's baseline
2262 + * commands existed if the user ever opened it.
2263 + *
2264 + * This builder lets Core do exactly what it would have done — the
2265 + * menu-command serialization and the inline init included — then
2266 + * UNWINDS the enqueue: it snapshots the script/style queues, calls
2267 + * the Core function, diffs out the roots it added, restores the
2268 + * queues so nothing prints at boot, and resolves the full ordered
2269 + * dependency chain on CLONES (the live `$to_do` is never touched).
2270 + * Each handle in the chain is harvested into the same
2271 + * url/before/after/l10n/translations shape the native-window lazy
2272 + * loader uses, and the shell replays the list — in order — the first
2273 + * time the palette is invoked (`src/commands/palette-assets.ts`).
2274 + *
2275 + * Handles with no `src` (pure aggregators) are kept whenever they
2276 + * carry inline data; dropping them would lose middleware and locale
2277 + * setup the chain depends on. Handles the boot page already printed
2278 + * are skipped client-side, by handle as well as by path so that a
2279 + * package Core concatenated into `load-scripts.php` is recognized
2280 + * (`src/script-presence.ts`) — the manifest deliberately lists them
2281 + * anyway, because which ones those are differs per site and per
2282 + * screen. Each entry therefore carries its `handle`, and that is
2283 + * load-bearing rather than informational.
2284 + *
2285 + * Returns `null` on pre-6.9 sites (no Core palette to defer).
2286 + *
2287 + * @return array{scripts:array<int,array<string,mixed>>,styles:array<int,array<string,mixed>>}|null
2288 + */
2289 +function openstation_build_command_palette_assets_payload() {
2290 + if ( ! function_exists( 'wp_enqueue_command_palette_assets' ) ) {
2291 + return null;
2292 + }
2293 + $scripts = wp_scripts();
2294 + $styles = wp_styles();
2295 + if ( ! $scripts || ! $styles ) {
2296 + return null;
2297 + }
2298 +
2299 + // `wp_enqueue_command_palette_assets()` reads `$submenu` without
2300 + // guarding the global — initialize defensively (test contexts,
2301 + // edge-case admin requests where the menu wasn't built yet).
2302 + global $menu, $submenu;
2303 + // phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited -- initializing an unset global to its documented empty shape, not replacing a built menu.
2304 + if ( ! isset( $submenu ) || ! is_array( $submenu ) ) {
2305 + $submenu = array();
2306 + }
2307 + if ( ! isset( $menu ) || ! is_array( $menu ) ) {
2308 + $menu = array();
2309 + }
2310 + // phpcs:enable WordPress.WP.GlobalVariablesOverride.Prohibited
2311 +
2312 + $script_queue_before = $scripts->queue;
2313 + $style_queue_before = $styles->queue;
2314 +
2315 + wp_enqueue_command_palette_assets();
2316 +
2317 + $script_roots = array_values( array_diff( $scripts->queue, $script_queue_before ) );
2318 + $style_roots = array_values( array_diff( $styles->queue, $style_queue_before ) );
2319 +
2320 + // Unwind: the boot page must not print any of it. The inline init
2321 + // stays attached to the `wp-core-commands` HANDLE — that is the
2322 + // point: the harvest below captures it, and if some other screen
2323 + // legitimately enqueues the handle, it prints as Core intended.
2324 + $scripts->queue = $script_queue_before;
2325 + $styles->queue = $style_queue_before;
2326 +
2327 + $out = array(
2328 + 'scripts' => array(),
2329 + 'styles' => array(),
2330 + );
2331 +
2332 + // Ordered dependency chains, resolved on clones so the request's
2333 + // real `$to_do` / `$done` state is untouched.
2334 + $script_probe = clone $scripts;
2335 + $script_probe->to_do = array();
2336 + $script_probe->done = array();
2337 + $script_probe->all_deps( $script_roots );
2338 + foreach ( $script_probe->to_do as $handle ) {
2339 + $payload = openstation_resolve_script_payload( $handle );
2340 + // A src-less aggregator is kept only for its inline data — the
2341 + // resolver harvests that for an alias — and dropped when it
2342 + // carries none.
2343 + if ( '' === $payload['url']
2344 + && empty( $payload['before'] )
2345 + && empty( $payload['after'] )
2346 + && empty( $payload['l10n'] ) ) {
2347 + continue;
2348 + }
2349 + // Core's `initializeCommandPalette( {…} )` inline embeds the
2350 + // serialized admin-menu command list — ~20 KB that the boot
2351 + // page ALREADY carries as `window.__openStationMenuCommands`
2352 + // (the shell harvester's lookup, attached as a `before`
2353 + // inline on the main bundle, and the richer of the two: its
2354 + // URL derivation routes legacy file-path slugs through
2355 + // `menu_page_url()` where Core's regex takes them literally).
2356 + // Ship the list once: strip Core's embedded copy and
2357 + // synthesize the same call against the global, which is
2358 + // guaranteed present long before the manifest replays — it
2359 + // prints at boot, the replay waits for the first ⌘K.
2360 + if ( 'wp-core-commands' === $handle ) {
2361 + foreach ( array( 'before', 'after' ) as $position ) {
2362 + $payload[ $position ] = array_values(
2363 + array_filter(
2364 + $payload[ $position ],
2365 + static function ( $snippet ) {
2366 + return false === strpos( (string) $snippet, 'initializeCommandPalette(' );
2367 + }
2368 + )
2369 + );
2370 + }
2371 + $payload['after'][] = sprintf(
2372 + 'wp.coreCommands.initializeCommandPalette({"is_network_admin":%s,"menu_commands":window.__openStationMenuCommands||[]});',
2373 + is_network_admin() ? 'true' : 'false'
2374 + );
2375 + }
2376 +
2377 + $out['scripts'][] = array(
2378 + 'handle' => (string) $handle,
2379 + 'url' => $payload['url'],
2380 + 'before' => $payload['before'],
2381 + 'after' => $payload['after'],
2382 + 'l10n' => $payload['l10n'],
2383 + 'translations' => $payload['translations'],
2384 + );
2385 + }
2386 +
2387 + $style_probe = clone $styles;
2388 + $style_probe->to_do = array();
2389 + $style_probe->done = array();
2390 + $style_probe->all_deps( $style_roots );
2391 + foreach ( $style_probe->to_do as $handle ) {
2392 + $style_payload = openstation_resolve_style_payload( $handle );
2393 + if ( '' === $style_payload['url'] ) {
2394 + continue;
2395 + }
2396 + $out['styles'][] = array(
2397 + 'handle' => (string) $handle,
2398 + 'url' => $style_payload['url'],
2399 + 'inline' => $style_payload['inline'],
2400 + );
2401 + }
2402 +
2403 + return $out;
2404 +}
2405 +
2406 +/**
2407 + * Resolve a list of style handles into the `deferredStyles` config
2408 + * map: handle → `array( 'url' => …, 'inline' => string[] )`.
2409 + *
2410 + * For shell surfaces that render on demand but are NOT native
2411 + * windows — the Preferences panel, the AI assistant, the bug-report
2412 + * window — so the `styles` companion mechanism can't carry their
2413 + * CSS. The shell reads this map off `openStationConfig.deferredStyles`
2414 + * and injects each sheet the first time its surface opens
2415 + * (`ensureDeferredStyle()` in `src/deferred-styles.ts`).
2416 + *
2417 + * Handles that resolve to nothing (never registered) are dropped, so
2418 + * the client map only ever holds injectable entries.
2419 + *
2420 + * @param string[] $handles Registered style handles.
2421 + * @return array<string, array{url:string, inline:string[]}>
2422 + */
2423 +function openstation_build_deferred_styles( $handles ) {
2424 + $out = array();
2425 + foreach ( (array) $handles as $handle ) {
2426 + $handle = (string) $handle;
2427 + $payload = openstation_resolve_style_payload( $handle );
2428 + if ( '' === $payload['url'] ) {
2429 + continue;
2430 + }
2431 + $out[ $handle ] = $payload;
2432 + }
2433 + return $out;
2434 +}
2435 +
2436 +/**
1926 2437 * Fire a `_doing_it_wrong()` notice exactly once per handle per
1927 2438 * request. Shared by every `openstation_build_desktop_*_scripts_payload()`
1928 2439 * caller — payload builders run on every shell-config rebuild
1929 2440 * (multiple times per page load via REST + admin-bar refresh +
@@ -1952,9 +2463,9 @@
1952 2463 _doing_it_wrong(
1953 2464 esc_html( $function_name ),
1954 2465 sprintf(
1955 2466 /* translators: 1: kind ("Command"/"Settings-tab"/"Title-bar button"), 2: handle. */
1956 - esc_html__( '%1$s script handle "%2$s" is not registered with WordPress (no `wp_register_script` call found). The script will not load.', 'desktop-mode' ),
2467 + esc_html__( '%1$s script handle "%2$s" could not be resolved: no `wp_register_script( \'%2$s\', … )` call had run by the time the shell harvested its payload. Register the handle on `admin_enqueue_scripts` at priority 5 or earlier — the harvest itself runs at priority 10, and a handle registered alongside it may or may not exist yet depending on plugin load order. Until then the script will not load.', 'desktop-mode' ),
1957 2468 esc_html( $kind ),
1958 2469 esc_html( $handle )
1959 2470 ),
1960 2471 '0.8.1'
@@ -1996,28 +2507,153 @@
1996 2507 openstation_warn_unresolvable_script_handle( '', '', '__flush__' );
1997 2508 }
1998 2509
1999 2510 /**
2000 - * Serialize the server-declared native-window registry into the
2001 - * payload shape the shell consumes. For each entry registered via
2002 - * `openstation_register_window()`, we capture: the window's
2003 - * metadata (id/title/icon/placement/dimensions/autofocus), the
2004 - * rendered template HTML (by running the template callback into an
2005 - * output buffer), and the URL of the enqueued script handle (so
2006 - * mid-session activations can load the plugin's JS dynamically
2007 - * without a full shell reload).
2511 + * Collect the native-window payload: slim per-window entries plus a
2512 + * handle-keyed script-data map.
2008 2513 *
2009 - * @return array[]
2514 + * For each entry registered via `openstation_register_window()` the
2515 + * `windows` list captures the window's metadata
2516 + * (id/title/icon/placement/dimensions/autofocus), the rendered
2517 + * template HTML, and the HANDLE NAMES of its script, companions and
2518 + * tab scripts. The resolved data those handles stand for — URL plus
2519 + * harvested `wp_localize_script` / `wp_add_inline_script` /
2520 + * translations, see `openstation_resolve_script_payload()` — lives
2521 + * ONCE per handle in `scriptData`, and the shell joins the two on
2522 + * receipt (`hydrateServerEntries()` in `src/native-windows.ts`).
2523 + * Each loadable handle's entry also names its dependency closure in
2524 + * `deps` (ordered handles, every one of them a key of the same map)
2525 + * so the lazy loader can bring a bundle's declared packages — and
2526 + * a src-less alias carrying its config — into the tab before it.
2527 + *
2528 + * The split exists because script data is a property of the HANDLE,
2529 + * not of the window: every App Framework window rides
2530 + * `openstation-app-runtime`, and inlining each entry's resolved copy
2531 + * serialized the same localize blobs and the same shared config set
2532 + * four times over — `scriptL10n` alone was ~100 KB of the boot
2533 + * payload, most of it repetition. The synthesized
2534 + * `openStationWindowConfig[ id ]` assignments group by handle for
2535 + * the same reason they used to ride every sharing entry: the shell
2536 + * fetches a URL once, and a bundle can serve one window from inside
2537 + * another (the Users window mounts the Profile form, which reads the
2538 + * user-edit config), so whichever entry loads the bundle must
2539 + * deliver the whole handle's config set.
2540 + *
2541 + * Style data stays inline on the entries — it never had a
2542 + * duplication problem worth a second map ( companion styles across
2543 + * the whole registry total ~2 KB ).
2544 + *
2545 + * @return array{windows:array[],scriptData:array<string,array{url:string,before:string[],after:string[],l10n:string[],translations:string,deps:string[]}>}
2010 2546 */
2011 -function openstation_build_native_windows_payload() {
2547 +function openstation_collect_native_windows_payload() {
2548 + $empty = array(
2549 + 'windows' => array(),
2550 + 'scriptData' => array(),
2551 + );
2012 2552 if ( ! function_exists( 'openstation_native_window_registry' ) ) {
2013 - return array();
2553 + return $empty;
2014 2554 }
2555 +
2015 2556 $registry = openstation_native_window_registry();
2016 2557 if ( ! is_array( $registry ) ) {
2017 - return array();
2558 + return $empty;
2018 2559 }
2019 2560
2561 + // A window says which admin offers it (`admin` in its registration:
2562 + // `site`, `network` or `any`). Every native window OpenStation
2563 + // ships is site-scoped, reading the current site's REST API, so in
2564 + // the network admin a `users.php` tile meaning "everyone on the
2565 + // network" would open one site's user list; those stay off the
2566 + // network shell. A window that declares `network` (the Network app)
2567 + // is offered there and nowhere else.
2568 + //
2569 + // Dropping the site windows there is also what disarms the
2570 + // client-side URL remaps: they match on the tail of a pathname
2571 + // (`endsWith( '/users.php' )`) and the network admin serves
2572 + // same-named files one directory down, but with nothing registered
2573 + // `openById()` finds no window and the remap falls through to the
2574 + // iframe.
2575 + $registry = array_filter( $registry, 'openstation_native_window_offered_here' );
2576 +
2577 + $script_data = array();
2578 +
2579 + // Handles resolved as a bundle to LOAD (a window's script, a
2580 + // companion, a tab) and what that visit answered — the handle, or
2581 + // '' for nothing to load — as opposed to reached only as
2582 + // somebody's dependency. A handle can be both — resolved as a
2583 + // dependency first, then named as a window's own script — and
2584 + // only the bundle visit computes its own closure.
2585 + $resolved_as_bundle = array();
2586 +
2587 + // Resolve a handle into the map, once. Returns the handle when it
2588 + // resolved to something loadable, '' when it did not (never
2589 + // registered, no src) — the same silent drop the inline shape
2590 + // applied to companions and tab scripts.
2591 + //
2592 + // The handle's dependency closure rides along as `deps`: an
2593 + // ordered handle list, each of which lands in the same map. A
2594 + // bundle delivered lazily never goes through WordPress's own
2595 + // dependency resolution — the loader injects one URL — so a
2596 + // window declaring `wp-api-fetch` found `wp.apiFetch` undefined,
2597 + // and one whose config rides a src-less alias handle (a common
2598 + // shape: `wp_register_script( $h, false )` plus
2599 + // `wp_add_inline_script()`, declared as the bundle's dependency
2600 + // so it always runs first) booted with no config at all. Anything
2601 + // the document already ran is skipped on the client, so a page
2602 + // that carried the packages anyway pays nothing.
2603 + $collect_handle = static function ( $handle ) use ( &$script_data, &$resolved_as_bundle ) {
2604 + $handle = (string) $handle;
2605 + if ( '' === $handle ) {
2606 + return '';
2607 + }
2608 + if ( isset( $resolved_as_bundle[ $handle ] ) ) {
2609 + return $resolved_as_bundle[ $handle ];
2610 + }
2611 + $payload = isset( $script_data[ $handle ] )
2612 + ? $script_data[ $handle ]
2613 + : openstation_resolve_script_payload( $handle );
2614 + if ( '' === $payload['url'] ) {
2615 + $resolved_as_bundle[ $handle ] = '';
2616 + return '';
2617 + }
2618 + $resolved_as_bundle[ $handle ] = $handle;
2619 + $deps = array();
2620 + foreach ( openstation_resolve_script_dependencies( $handle ) as $dep ) {
2621 + $dep_handle = (string) $dep['handle'];
2622 + unset( $dep['handle'] );
2623 + if ( ! isset( $script_data[ $dep_handle ] ) ) {
2624 + $dep['deps'] = array();
2625 + $script_data[ $dep_handle ] = $dep;
2626 + }
2627 + $deps[] = $dep_handle;
2628 + }
2629 + $payload['deps'] = $deps;
2630 + $script_data[ $handle ] = $payload;
2631 + return $handle;
2632 + };
2633 +
2634 + // Synthesized `openStationWindowConfig[ id ]` assignments, grouped
2635 + // by script handle (see the function docblock). Collected first so
2636 + // they can be appended to each handle's map entry exactly once,
2637 + // after its own harvested data — the same order the print pipeline
2638 + // would have used.
2639 + $config_snippets_by_handle = array();
2640 + foreach ( $registry as $entry ) {
2641 + $handle = isset( $entry['script'] ) ? (string) $entry['script'] : '';
2642 + if ( '' === $handle || ! is_callable( $entry['template'] ) ) {
2643 + continue;
2644 + }
2645 + $window_config = openstation_filter_native_window_config( $entry );
2646 + if ( empty( $window_config ) ) {
2647 + continue;
2648 + }
2649 + $config_snippets_by_handle[ $handle ][ $entry['id'] ] = sprintf(
2650 + 'window.openStationWindowConfig=window.openStationWindowConfig||{};window.openStationWindowConfig[%s]=%s;',
2651 + wp_json_encode( $entry['id'] ),
2652 + wp_json_encode( $window_config )
2653 + );
2654 + }
2655 +
2020 2656 $out = array();
2021 2657 foreach ( $registry as $entry ) {
2022 2658 if ( ! is_callable( $entry['template'] ) ) {
2023 2659 continue;
@@ -2030,40 +2666,34 @@
2030 2666 // `<template>` at mid-session plugin activation without a
2031 2667 // reload.
2032 2668 $template_html = openstation_build_native_window_template_html( $entry );
2033 2669
2034 - // Resolve script handle → full payload (URL + harvested
2035 - // `extra` data) so the shell can inject a `<script>` tag
2036 - // dynamically on mid-session activation WITHOUT dropping
2037 - // `wp_localize_script` / `wp_add_inline_script` data the way
2038 - // the bare `<script src>` lazy-load path would. See
2039 - // `openstation_resolve_script_payload()` for shape.
2040 - $script_handle = isset( $entry['script'] ) ? (string) $entry['script'] : '';
2041 - $script_payload = openstation_resolve_script_payload( $script_handle );
2670 + // `$collect_handle()` answers "is there a bundle to fetch?", and
2671 + // returns '' when the handle resolves to no URL — a src-less
2672 + // alias handle registered only to carry `preload_script` or
2673 + // inline data, for instance. That is the right answer for
2674 + // `scriptHandle`, which names something to load. It is the
2675 + // wrong answer for `ownerHandle`, which names WHO the window
2676 + // belongs to: attribution does not depend on whether the owner
2677 + // happens to ship a file. Shipping '' there broke the
2678 + // documented "always populated" contract and blanked
2679 + // `wp.os.debug.window()`.
2680 + $declared_script = isset( $entry['script'] ) ? (string) $entry['script'] : '';
2681 + $script_handle = $collect_handle( $declared_script );
2682 + $owner_handle = '' !== $script_handle ? $script_handle : $declared_script;
2042 2683
2043 2684 // Companion handles (`scripts` arg) — bundles that extend the
2044 2685 // window from outside it and must be in the tab before its
2045 - // render callback paints. Same resolved shape as the main
2046 - // script, kept as a list so the shell loads them in the
2047 - // declared order ahead of it. Handles that resolve to nothing
2048 - // (never registered) are dropped rather than shipped as an
2049 - // entry the loader would skip anyway.
2686 + // render callback paints. Kept as an ordered handle list; the
2687 + // shell loads them in declared order ahead of the window's
2688 + // own script, resolving each through `scriptData`.
2050 2689 $companion_scripts = array();
2051 2690 if ( ! empty( $entry['scripts'] ) && is_array( $entry['scripts'] ) ) {
2052 2691 foreach ( $entry['scripts'] as $companion_handle ) {
2053 - $companion_handle = (string) $companion_handle;
2054 - $companion_payload = openstation_resolve_script_payload( $companion_handle );
2055 - if ( '' === $companion_payload['url'] ) {
2056 - continue;
2692 + $companion_handle = $collect_handle( $companion_handle );
2693 + if ( '' !== $companion_handle ) {
2694 + $companion_scripts[] = $companion_handle;
2057 2695 }
2058 - $companion_scripts[] = array(
2059 - 'scriptUrl' => $companion_payload['url'],
2060 - 'scriptHandle' => $companion_handle,
2061 - 'scriptBefore' => $companion_payload['before'],
2062 - 'scriptAfter' => $companion_payload['after'],
2063 - 'scriptL10n' => $companion_payload['l10n'],
2064 - 'scriptTranslations' => $companion_payload['translations'],
2065 - );
2066 2696 }
2067 2697 }
2068 2698
2069 2699 // Resolve the optional style handle alongside the script so the
@@ -2073,90 +2703,124 @@
2073 2703 // handle isn't registered — both treated as "no styles to load."
2074 2704 $style_handle = isset( $entry['style'] ) ? (string) $entry['style'] : '';
2075 2705 $style_payload = openstation_resolve_style_payload( $style_handle );
2076 2706
2077 - // `config` arg on `openstation_register_window()` — discoverable
2078 - // alternative to `wp_localize_script`. We synthesize a localize
2079 - // snippet so it lands through the same delivery path as native
2080 - // `wp_localize_script`. The bundle reads
2081 - // `window.openStationWindowConfig[id]` (or via
2082 - // `wp.os.getWindowConfig(id)`).
2083 - $window_config = openstation_filter_native_window_config( $entry );
2084 - if ( ! empty( $window_config ) ) {
2085 - $script_payload['l10n'][] = sprintf(
2086 - 'window.openStationWindowConfig=window.openStationWindowConfig||{};window.openStationWindowConfig[%s]=%s;',
2087 - wp_json_encode( $entry['id'] ),
2088 - wp_json_encode( $window_config )
2089 - );
2707 + // Companion style handles (`styles` arg) — stylesheets the
2708 + // shell injects on the window's FIRST OPEN, after the window's
2709 + // own style, in declared order. The styles-side mirror of
2710 + // `companionScripts`, with different timing on purpose: the
2711 + // window's own `style` lands when the window registers so a
2712 + // mid-session activation paints, but a companion exists to be
2713 + // deferred — it costs nothing until the window is actually
2714 + // shown. Unregistered handles drop, same as script companions.
2715 + $companion_styles = array();
2716 + if ( ! empty( $entry['styles'] ) && is_array( $entry['styles'] ) ) {
2717 + foreach ( $entry['styles'] as $companion_style_handle ) {
2718 + $companion_style_handle = (string) $companion_style_handle;
2719 + $companion_style_payload = openstation_resolve_style_payload( $companion_style_handle );
2720 + if ( '' === $companion_style_payload['url'] ) {
2721 + continue;
2722 + }
2723 + $companion_styles[] = array(
2724 + 'styleUrl' => $companion_style_payload['url'],
2725 + 'styleHandle' => $companion_style_handle,
2726 + 'styleInline' => $companion_style_payload['inline'],
2727 + );
2728 + }
2090 2729 }
2091 2730
2092 - // Tab metadata (label + extra script payloads) ships alongside
2093 - // the template so the shell can render a picker UI or load
2094 - // additional tab scripts when a tab's activation is late.
2731 + // Tab metadata ships alongside the template so the shell can
2732 + // render a picker UI, and each tab's script handle joins the
2733 + // map so a late tab activation can still load its bundle.
2095 2734 $tab_descriptors = array();
2096 2735 if ( function_exists( 'openstation_get_native_window_tabs' ) ) {
2097 2736 foreach ( openstation_get_native_window_tabs( $entry['id'] ) as $tab ) {
2098 - // The resolver returns the empty payload shape itself
2099 - // for an empty handle — no need to hand-write it here.
2100 - $tab_payload = openstation_resolve_script_payload( $tab['script'] );
2101 2737 $tab_descriptors[] = array(
2102 - 'value' => $tab['value'],
2103 - 'label' => $tab['label'],
2104 - 'isMain' => $tab['is_main'],
2105 - 'scriptUrl' => $tab_payload['url'],
2106 - 'scriptHandle' => $tab['script'],
2107 - 'scriptBefore' => $tab_payload['before'],
2108 - 'scriptAfter' => $tab_payload['after'],
2109 - 'scriptL10n' => $tab_payload['l10n'],
2110 - 'scriptTranslations' => $tab_payload['translations'],
2738 + 'value' => $tab['value'],
2739 + 'label' => $tab['label'],
2740 + 'isMain' => $tab['is_main'],
2741 + 'scriptHandle' => $collect_handle( $tab['script'] ),
2111 2742 );
2112 2743 }
2113 2744 }
2114 2745
2115 2746 $out[] = array(
2116 - 'id' => $entry['id'],
2117 - 'title' => $entry['title'],
2118 - 'icon' => $entry['icon'],
2119 - 'placement' => $entry['placement'],
2747 + 'id' => $entry['id'],
2748 + 'title' => $entry['title'],
2749 + 'icon' => $entry['icon'],
2750 + 'placement' => $entry['placement'],
2120 2751 // `'app'` or `'control'` — the navigation kind, which
2121 2752 // decides the launcher's default placement and its dock
2122 2753 // zone. See `src/nav/defaults.ts`.
2123 - 'navKind' => isset( $entry['nav_kind'] ) ? $entry['nav_kind'] : 'app',
2754 + 'navKind' => isset( $entry['nav_kind'] ) ? $entry['nav_kind'] : 'app',
2124 2755 // Sort key among system tiles. Absent / 0 puts a plugin's
2125 2756 // launcher ahead of the shell's own trailing cluster.
2126 - 'dockOrder' => isset( $entry['dock_order'] ) ? (int) $entry['dock_order'] : 0,
2127 - 'placeable' => ! empty( $entry['placeable'] ),
2128 - 'width' => $entry['width'],
2129 - 'height' => $entry['height'],
2130 - 'minWidth' => $entry['min_width'],
2131 - 'minHeight' => $entry['min_height'],
2132 - 'autofocus' => $entry['autofocus'],
2133 - 'templateId' => 'os-native-window-' . $entry['id'],
2134 - 'templateHtml' => $template_html,
2135 - 'scriptUrl' => $script_payload['url'],
2136 - 'scriptHandle' => $script_handle,
2137 - 'ownerHandle' => $script_handle,
2138 - 'scriptBefore' => $script_payload['before'],
2139 - 'scriptAfter' => $script_payload['after'],
2140 - 'scriptL10n' => $script_payload['l10n'],
2141 - 'scriptTranslations' => $script_payload['translations'],
2142 - 'companionScripts' => $companion_scripts,
2757 + 'dockOrder' => isset( $entry['dock_order'] ) ? (int) $entry['dock_order'] : 0,
2758 + 'placeable' => ! empty( $entry['placeable'] ),
2759 + 'width' => $entry['width'],
2760 + 'height' => $entry['height'],
2761 + 'minWidth' => $entry['min_width'],
2762 + 'minHeight' => $entry['min_height'],
2763 + 'autofocus' => $entry['autofocus'],
2764 + 'templateId' => 'os-native-window-' . $entry['id'],
2765 + 'templateHtml' => $template_html,
2766 + 'scriptHandle' => $script_handle,
2767 + 'ownerHandle' => $owner_handle,
2768 + 'companionScripts' => $companion_scripts,
2143 2769 // Whether the shell loads the bundle at boot rather than on
2144 2770 // first open. Off by default: a window's script is dead
2145 2771 // weight on every admin page until the window is actually
2146 2772 // opened.
2147 - 'preloadScript' => ! empty( $entry['preload_script'] ),
2148 - 'styleUrl' => $style_payload['url'],
2149 - 'styleHandle' => $style_handle,
2150 - 'styleInline' => $style_payload['inline'],
2151 - 'tabs' => $tab_descriptors,
2773 + 'preloadScript' => ! empty( $entry['preload_script'] ),
2774 + 'styleUrl' => $style_payload['url'],
2775 + 'styleHandle' => $style_handle,
2776 + 'styleInline' => $style_payload['inline'],
2777 + 'companionStyles' => $companion_styles,
2778 + 'tabs' => $tab_descriptors,
2779 + 'menuPages' => isset( $entry['menu_pages'] ) ? array_values( (array) $entry['menu_pages'] ) : array(),
2152 2780 );
2153 2781 }
2154 2782
2155 - return $out;
2783 + // Append each handle's synthesized config set to its map entry —
2784 + // once, after the handle's own harvested data. The snippets land
2785 + // in REGISTRY-ITERATION order for every consumer of the handle;
2786 + // the old per-entry shape put each window's own config first, an
2787 + // ordering nothing could observe (each snippet assigns a distinct
2788 + // `openStationWindowConfig[ id ]` key and none reads another), so
2789 + // it is deliberately not preserved. Configs for handles that
2790 + // resolved to nothing are undeliverable and drop, exactly as they
2791 + // always did.
2792 + foreach ( $config_snippets_by_handle as $handle => $snippets ) {
2793 + if ( ! isset( $script_data[ $handle ] ) ) {
2794 + continue;
2795 + }
2796 + foreach ( $snippets as $snippet ) {
2797 + $script_data[ $handle ]['l10n'][] = $snippet;
2798 + }
2799 + }
2800 +
2801 + return array(
2802 + 'windows' => $out,
2803 + 'scriptData' => $script_data,
2804 + );
2156 2805 }
2157 2806
2158 2807 /**
2808 + * The `windows` half of {@see openstation_collect_native_windows_payload()}.
2809 + *
2810 + * Kept as the historical entry point — tests and older call sites
2811 + * ask for the entry list alone. Anything that also needs the
2812 + * script-data map (everything that actually LOADS a bundle) should
2813 + * call the collector and take both halves from one build.
2814 + *
2815 + * @return array[]
2816 + */
2817 +function openstation_build_native_windows_payload() {
2818 + $bundle = openstation_collect_native_windows_payload();
2819 + return $bundle['windows'];
2820 +}
2821 +
2822 +/**
2159 2823 * Cleans a `$menu` / `$submenu` title for display.
2160 2824 *
2161 2825 * Strips badge spans first (`<span class="update-plugins count-3">`),
2162 2826 * then any remaining markup. An empty result means the entry has no
@@ -2214,13 +2878,45 @@
2214 2878 return file_exists( ABSPATH . 'wp-admin/' . $file );
2215 2879 }
2216 2880
2217 2881 /**
2882 + * The admin URL a menu slug resolves against.
2883 + *
2884 + * Follows the admin the request is in: the network admin's own URL there,
2885 + * because its globals carry network slugs (`sites.php`, `settings.php`)
2886 + * that exist only under `wp-admin/network/`, and the site admin's
2887 + * everywhere else.
2888 + *
2889 + * The same answer `self_admin_url()` gives, without its filter. That
2890 + * filter receives the path, so a host can use it to send one screen
2891 + * somewhere else, and WordPress.com points `plugin-install.php` at its own
2892 + * installer. Resolved through it, the wp-admin original of a menu row the
2893 + * host replaced reads as off-site, and the dock drops it along with the
2894 + * replacement, which is how Plugins > Add Plugin disappears there.
2895 + *
2896 + * @param string $path Optional. Path relative to the admin URL.
2897 + * @return string Absolute admin URL.
2898 + */
2899 +function openstation_menu_admin_url( $path = '' ) {
2900 + if ( is_network_admin() ) {
2901 + return network_admin_url( $path );
2902 + }
2903 + if ( is_user_admin() ) {
2904 + return user_admin_url( $path );
2905 + }
2906 + return admin_url( $path );
2907 +}
2908 +
2909 +/**
2218 2910 * Converts a menu item slug to a full admin URL.
2219 2911 *
2912 + * Resolution goes through {@see openstation_menu_admin_url()}, which
2913 + * follows the admin the request is in without passing through the
2914 + * filterable `self_admin_url()`.
2915 + *
2220 2916 * Handles three slug shapes:
2221 2917 * 1. Direct file references (`edit.php`, `upload.php`) — passed
2222 - * through `admin_url()` as-is.
2918 + * through `openstation_menu_admin_url()` as-is.
2223 2919 * 2. Plain plugin page slugs (`my-plugin`) — routed through
2224 2920 * `admin.php?page=<slug>` with the slug `rawurlencode()`d.
2225 2921 * 3. Plugin page slugs that embed extra query parameters
2226 2922 * (`wc-admin&path=/customers`) — split on the first `&`, the
@@ -2281,9 +2977,9 @@
2281 2977 if (
2282 2978 false !== strpos( $slug, '.php' ) &&
2283 2979 ( ! isset( $_parent_pages[ $slug ] ) || openstation_is_admin_file_slug( $slug ) )
2284 2980 ) {
2285 - return esc_url_raw( admin_url( $slug ) );
2981 + return esc_url_raw( openstation_menu_admin_url( $slug ) );
2286 2982 }
2287 2983
2288 2984 // Plugin page slug with embedded query parameters
2289 2985 // (e.g., 'wc-admin&path=/customers'). Split the page slug from
@@ -2323,9 +3019,9 @@
2323 3019 $host = add_query_arg( 'page', $slug, $parent_slug );
2324 3020 }
2325 3021 }
2326 3022
2327 - $url = admin_url( $host );
3023 + $url = openstation_menu_admin_url( $host );
2328 3024 if ( ! empty( $extra_args ) ) {
2329 3025 $url = add_query_arg( $extra_args, $url );
2330 3026 }
2331 3027 return esc_url_raw( $url );