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/core/payload.php +486 -166 1.1.31.1.10 View file →
@@ -454,10 +454,12 @@
454 454 * URLs) would therefore fill the dock with tiles that can only ever
455 455 * escape to a browser tab, which breaks the shell's navigation model.
456 456 * Those entries are dropped from the payload instead.
457 457 *
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.
458 + * The menu's own admin (`openstation_menu_admin_url()`), `admin_url()`
459 + * and `home_url()` hosts all count as ours: a site can run its admin on
460 + * a different domain than its front end, and the network admin lives on
461 + * the network's own.
460 462 *
461 463 * @param string $url Absolute URL, as returned by `openstation_menu_item_url()`.
462 464 * @return bool True when the URL is off-site.
463 465 */
@@ -466,9 +468,9 @@
466 468 $external = false;
467 469
468 470 if ( $host ) {
469 471 $ours = array();
470 - foreach ( array( admin_url(), home_url() ) as $known ) {
472 + foreach ( array( openstation_menu_admin_url(), admin_url(), home_url() ) as $known ) {
471 473 $known_host = wp_parse_url( $known, PHP_URL_HOST );
472 474 if ( $known_host ) {
473 475 $ours[] = strtolower( $known_host );
474 476 }
@@ -817,8 +819,19 @@
817 819 'link-manager.php', // Link manager (legacy)
818 820 'update-core.php', // Dashboard > Updates
819 821 );
820 822
823 + // The two top-level network menus the site admin has no filename
824 + // for: without them, Sites and Settings sat in the apps zone while
825 + // Dashboard, Users, Themes and Plugins — whose filenames the site
826 + // admin shares — grouped correctly. Gated on the context, since
827 + // `settings.php` is plausible enough as a plugin's own top-level
828 + // slug that claiming it everywhere would misfile it.
829 + if ( is_network_admin() ) {
830 + $core_files[] = 'sites.php';
831 + $core_files[] = 'settings.php';
832 + }
833 +
821 834 return in_array( $base, $core_files, true );
822 835 }
823 836
824 837 /**
@@ -1583,11 +1596,16 @@
1583 1596 }
1584 1597
1585 1598 $dock = array_merge( $core, $plugin );
1586 1599
1600 + // One collector call feeds both halves: the slim entry list and
1601 + // the handle-keyed script data the shell joins them with.
1602 + $native_windows = openstation_collect_native_windows_payload();
1603 +
1587 1604 $payload = array(
1588 - 'dockItems' => $dock,
1589 - 'nativeWindows' => openstation_build_native_windows_payload(),
1605 + 'dockItems' => $dock,
1606 + 'nativeWindows' => $native_windows['windows'],
1607 + 'nativeWindowScriptData' => $native_windows['scriptData'],
1590 1608 );
1591 1609
1592 1610 // Optional per-surface payload builders — each module ships a
1593 1611 // zero-arg `openstation_build_*_payload()`; modules that aren't
@@ -1645,8 +1663,15 @@
1645 1663 'url' => network_admin_url( 'update-core.php' ),
1646 1664 );
1647 1665 }
1648 1666
1667 + // The site switcher's rows: on a network, the instances this shell
1668 + // may switch to (`openstation_multisite_payload()`), null elsewhere.
1669 + // The Network app spends a menu refresh after every action that
1670 + // changes them (add, remove, join, leave, sync), so the row above
1671 + // overview's desktop tiles follows the registry without a reload.
1672 + $payload['multisite'] = openstation_multisite_payload();
1673 +
1649 1674 // A cheap structural fingerprint of the admin menu the shell uses to
1650 1675 // decide whether a live refresh is warranted. Shipped in every full
1651 1676 // payload so the shell can seed / update its last-known signature
1652 1677 // without recomputing it client-side (which would risk drift from
@@ -1729,8 +1754,153 @@
1729 1754 return md5( implode( "\n", $parts ) );
1730 1755 }
1731 1756
1732 1757 /**
1758 + * A handle's dependency closure, in load order.
1759 + *
1760 + * Post-order depth-first: a handle is emitted only after everything it
1761 + * declares, which is the order `WP_Scripts::do_item()` would have
1762 + * printed them in. A handle is marked visited *before* its own
1763 + * dependencies are walked, so a dependency cycle unwinds instead of
1764 + * recursing forever, and an unregistered handle is skipped rather than
1765 + * being fatal — it contributes nothing and stops nothing.
1766 + *
1767 + * **Deliberately not `WP_Dependencies::all_deps()`.** Three reasons,
1768 + * each of which has bitten this codebase:
1769 + *
1770 + * 1. `WP_Scripts::all_deps()` applies `print_scripts_array` to its
1771 + * result whenever `$recursion` is falsy. That filter is where the
1772 + * chromeless palette trim and the asset guard live, so resolving a
1773 + * payload through it would run a print-time trim across a dependency
1774 + * list and let the guard splice this plugin's own bundles into it.
1775 + * Called from inside one of those filters it is an infinite loop.
1776 + *
1777 + * 2. Passing `$recursion = true` silences that filter but changes the
1778 + * contract: the first handle that fails aborts the entire call
1779 + * (`return false`), abandoning every handle after it in the list. The
1780 + * caller is left with a `$to_do` that is a truncated prefix of the real
1781 + * closure and indistinguishable from a complete one — a silent, partial
1782 + * answer conditional on unrelated registrations elsewhere on the page.
1783 + * A lazily-delivered bundle resolved that way loses packages it
1784 + * declared and throws on an undefined global at mount, which is the
1785 + * exact bug this whole mechanism exists to prevent.
1786 + *
1787 + * 3. `all_deps()` reports missing dependencies through
1788 + * `_doing_it_wrong()`. This is read-only analysis; the real print pass
1789 + * raises those anyway, and raising them twice turns someone else's
1790 + * pre-existing warning into our noise.
1791 + *
1792 + * O(V+E) over the graph, allocates one set, and clones nothing.
1793 + *
1794 + * @param WP_Dependencies $dependencies The scripts or styles registry.
1795 + * @param string[] $handles Roots to walk.
1796 + * @return string[] Registered handles, dependencies before dependents.
1797 + */
1798 +function openstation_script_dependency_closure( $dependencies, $handles ) {
1799 + $seen = array();
1800 + $out = array();
1801 + openstation_collect_script_dependency_closure( $dependencies, (array) $handles, $seen, $out );
1802 +
1803 + return $out;
1804 +}
1805 +
1806 +/**
1807 + * Recursive half of {@see openstation_script_dependency_closure()}.
1808 + *
1809 + * @param WP_Dependencies $dependencies The scripts or styles registry.
1810 + * @param string[] $handles Handles to walk.
1811 + * @param array $seen Handle => true, by reference.
1812 + * @param string[] $out Ordered result, by reference.
1813 + */
1814 +function openstation_collect_script_dependency_closure( $dependencies, $handles, &$seen, &$out ) {
1815 + foreach ( (array) $handles as $handle ) {
1816 + if ( isset( $seen[ $handle ] ) ) {
1817 + continue;
1818 + }
1819 + // Marked BEFORE recursing, so a cycle meets itself as visited
1820 + // and unwinds rather than recursing forever.
1821 + $seen[ $handle ] = true;
1822 + if ( ! isset( $dependencies->registered[ $handle ] ) ) {
1823 + continue;
1824 + }
1825 + openstation_collect_script_dependency_closure(
1826 + $dependencies,
1827 + $dependencies->registered[ $handle ]->deps,
1828 + $seen,
1829 + $out
1830 + );
1831 + $out[] = $handle;
1832 + }
1833 +}
1834 +
1835 +/**
1836 + * Resolve a handle's dependency closure, in load order.
1837 + *
1838 + * **Why a lazily-delivered handle needs this at all.** WordPress
1839 + * normally resolves a script's dependencies when it enqueues it — the
1840 + * packages a bundle declares are on the page before its own body runs.
1841 + * A handle that is only ever delivered lazily never goes through that:
1842 + * `loadVendorScript()` injects one URL, and a bundle declaring
1843 + * `wp-api-fetch` found `wp.apiFetch` undefined at mount.
1844 + *
1845 + * That used to work by accident. Core's ⌘K palette was enqueued on
1846 + * every admin page and its closure is the whole Gutenberg runtime, so
1847 + * `wp.apiFetch`, `wp.element` and friends happened to be globals.
1848 + * Deferring the palette took the accident away and left the contract
1849 + * exposed — see `docs/migration-wp-package-globals.md`.
1850 + *
1851 + * The closure comes from {@see openstation_script_dependency_closure()}
1852 + * rather than `WP_Dependencies::all_deps()`; that function's docblock
1853 + * records why, and the short version is that `all_deps()` answers a
1854 + * question like this one with a silently truncated list. The handle
1855 + * itself is excluded — the caller loads it separately, after these.
1856 + *
1857 + * @param string $handle Script handle.
1858 + * @return array<int,array<string,mixed>> Ordered dependency payloads.
1859 + */
1860 +function openstation_resolve_script_dependencies( $handle ) {
1861 + $handle = (string) $handle;
1862 + $wp_scripts = wp_scripts();
1863 + if ( '' === $handle || ! $wp_scripts || ! isset( $wp_scripts->registered[ $handle ] ) ) {
1864 + return array();
1865 + }
1866 + $deps = $wp_scripts->registered[ $handle ]->deps;
1867 + if ( empty( $deps ) ) {
1868 + return array();
1869 + }
1870 +
1871 + $out = array();
1872 + foreach ( openstation_script_dependency_closure( $wp_scripts, $deps ) as $dep_handle ) {
1873 + if ( $dep_handle === $handle ) {
1874 + continue;
1875 + }
1876 + $payload = openstation_resolve_script_payload( $dep_handle );
1877 + // An alias (no `src`) stays in the list when it carries inline
1878 + // data — that data is the whole reason it was declared, and a
1879 + // plugin's config blob commonly rides one. Nothing to fetch
1880 + // AND nothing to run is the only thing dropped.
1881 + if ( '' === $payload['url']
1882 + && empty( $payload['before'] )
1883 + && empty( $payload['after'] )
1884 + && empty( $payload['l10n'] ) ) {
1885 + continue;
1886 + }
1887 + // The handle rides along because the shell needs it to decide
1888 + // whether the page already has this package. A URL is not
1889 + // enough: with Core's script concatenation on — the wp-admin
1890 + // default — every package below `wp-includes/js/` is served
1891 + // from one `load-scripts.php` blob and has no `<script src>`
1892 + // of its own to match against. Re-running `wp-hooks` because
1893 + // we could not see it replaces `window.wp.hooks`, and every
1894 + // subscriber registered at boot goes deaf. See
1895 + // `src/script-presence.ts`.
1896 + $payload['handle'] = (string) $dep_handle;
1897 + $out[] = $payload;
1898 + }
1899 + return $out;
1900 +}
1901 +
1902 +/**
1733 1903 * Resolve a registered WP script handle into the full payload the
1734 1904 * shell needs to lazy-load it without going through `wp_print_scripts()`.
1735 1905 *
1736 1906 * Returns:
@@ -1754,10 +1924,14 @@
1754 1924 * around the lazy `<script src>` in the same order
1755 1925 * `WP_Scripts::do_item()` would have used.
1756 1926 *
1757 1927 * 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."
1928 + * is unregistered. A registered handle with no source — an alias
1929 + * carrying only inline data — also comes back with an empty `url`,
1930 + * but its `before` / `after` / `l10n` are kept: callers that load a
1931 + * bundle treat an empty `url` as "nothing to fetch", and the
1932 + * dependency walk ({@see openstation_resolve_script_dependencies()})
1933 + * still replays what the alias would have printed.
1760 1934 *
1761 1935 * Shared between `openstation_register_window()` and
1762 1936 * `openstation_register_widget()` (and every other registration that
1763 1937 * relies on lazy script loading in the shell) because all of them
@@ -1785,20 +1959,31 @@
1785 1959 return $empty;
1786 1960 }
1787 1961 $registered = $wp_scripts->registered[ $handle ];
1788 1962 $src = is_string( $registered->src ) ? $registered->src : '';
1789 - if ( '' === $src ) {
1790 - return $empty;
1791 - }
1792 1963
1793 - // Normalize relative paths + attach cache-bust ver.
1794 - $resolved = $src;
1795 - if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) {
1796 - $resolved = site_url( $resolved );
1964 + // A handle with no `src` is an ALIAS — WordPress's supported way
1965 + // to ship inline-only JavaScript (`wp_register_script( $h, false )`
1966 + // plus `wp_add_inline_script()`), and a common home for a plugin's
1967 + // config blob: registering it as a *dependency* of every bundle is
1968 + // what guarantees the config runs first, whatever the enqueue
1969 + // order. `WP_Scripts::do_item()` prints an alias's localized data
1970 + // and its before/after snippets and returns before the `<script
1971 + // src>` it does not have. The payload mirrors that: `url` stays
1972 + // empty (there is nothing to fetch) and the inline data is kept,
1973 + // so a dependency walk can replay it. Translations are not: Core
1974 + // only prints those for a handle it printed a tag for.
1975 + $resolved = '';
1976 + if ( '' !== $src ) {
1977 + // Normalize relative paths + attach cache-bust ver.
1978 + $resolved = $src;
1979 + if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) {
1980 + $resolved = site_url( $resolved );
1981 + }
1982 + if ( ! empty( $registered->ver ) ) {
1983 + $resolved = add_query_arg( 'ver', $registered->ver, $resolved );
1984 + }
1797 1985 }
1798 - if ( ! empty( $registered->ver ) ) {
1799 - $resolved = add_query_arg( 'ver', $registered->ver, $resolved );
1800 - }
1801 1986
1802 1987 // Harvest `extra` data the lazy-load path would otherwise drop.
1803 1988 $before = array();
1804 1989 $after = array();
@@ -1833,9 +2018,9 @@
1833 2018 // `wp.i18n.setLocaleData( JSON, 'domain' )` snippet that the print
1834 2019 // pipeline emits before the script body. `print_translations(
1835 2020 // $handle, false )` returns the snippet without echoing.
1836 2021 $translations = '';
1837 - if ( method_exists( $wp_scripts, 'print_translations' ) ) {
2022 + if ( '' !== $resolved && method_exists( $wp_scripts, 'print_translations' ) ) {
1838 2023 $captured = $wp_scripts->print_translations( $handle, false );
1839 2024 if ( is_string( $captured ) ) {
1840 2025 $translations = $captured;
1841 2026 }
@@ -1946,12 +2131,15 @@
1946 2131 * time the palette is invoked (`src/commands/palette-assets.ts`).
1947 2132 *
1948 2133 * Handles with no `src` (pure aggregators) are kept whenever they
1949 2134 * carry inline data; dropping them would lose middleware and locale
1950 - * setup the chain depends on. Handles another plugin already
1951 - * enqueued at boot print normally and are skipped client-side by a
1952 - * same-path DOM sniff — the manifest deliberately lists them anyway,
1953 - * because which ones those are differs per site and per screen.
2135 + * setup the chain depends on. Handles the boot page already printed
2136 + * are skipped client-side, by handle as well as by path so that a
2137 + * package Core concatenated into `load-scripts.php` is recognized
2138 + * (`src/script-presence.ts`) — the manifest deliberately lists them
2139 + * anyway, because which ones those are differs per site and per
2140 + * screen. Each entry therefore carries its `handle`, and that is
2141 + * load-bearing rather than informational.
1954 2142 *
1955 2143 * Returns `null` on pre-6.9 sites (no Core palette to defer).
1956 2144 *
1957 2145 * @return array{scripts:array<int,array<string,mixed>>,styles:array<int,array<string,mixed>>}|null
@@ -2006,25 +2194,45 @@
2006 2194 $script_probe->done = array();
2007 2195 $script_probe->all_deps( $script_roots );
2008 2196 foreach ( $script_probe->to_do as $handle ) {
2009 2197 $payload = openstation_resolve_script_payload( $handle );
2010 - if ( '' === $payload['url'] ) {
2011 - // Src-less aggregator — keep it only for its inline data.
2012 - $registered = isset( $scripts->registered[ $handle ] ) ? $scripts->registered[ $handle ] : null;
2013 - if ( $registered ) {
2014 - foreach ( array( 'before', 'after' ) as $position ) {
2015 - if ( isset( $registered->extra[ $position ] ) && is_array( $registered->extra[ $position ] ) ) {
2016 - $payload[ $position ] = array_values( array_filter( array_map( 'strval', $registered->extra[ $position ] ) ) );
2017 - }
2018 - }
2019 - if ( ! empty( $registered->extra['data'] ) && is_string( $registered->extra['data'] ) ) {
2020 - $payload['l10n'][] = $registered->extra['data'];
2021 - }
2198 + // A src-less aggregator is kept only for its inline data — the
2199 + // resolver harvests that for an alias — and dropped when it
2200 + // carries none.
2201 + if ( '' === $payload['url']
2202 + && empty( $payload['before'] )
2203 + && empty( $payload['after'] )
2204 + && empty( $payload['l10n'] ) ) {
2205 + continue;
2206 + }
2207 + // Core's `initializeCommandPalette( {…} )` inline embeds the
2208 + // serialized admin-menu command list — ~20 KB that the boot
2209 + // page ALREADY carries as `window.__openStationMenuCommands`
2210 + // (the shell harvester's lookup, attached as a `before`
2211 + // inline on the main bundle, and the richer of the two: its
2212 + // URL derivation routes legacy file-path slugs through
2213 + // `menu_page_url()` where Core's regex takes them literally).
2214 + // Ship the list once: strip Core's embedded copy and
2215 + // synthesize the same call against the global, which is
2216 + // guaranteed present long before the manifest replays — it
2217 + // prints at boot, the replay waits for the first ⌘K.
2218 + if ( 'wp-core-commands' === $handle ) {
2219 + foreach ( array( 'before', 'after' ) as $position ) {
2220 + $payload[ $position ] = array_values(
2221 + array_filter(
2222 + $payload[ $position ],
2223 + static function ( $snippet ) {
2224 + return false === strpos( (string) $snippet, 'initializeCommandPalette(' );
2225 + }
2226 + )
2227 + );
2022 2228 }
2023 - if ( empty( $payload['before'] ) && empty( $payload['after'] ) && empty( $payload['l10n'] ) ) {
2024 - continue;
2025 - }
2229 + $payload['after'][] = sprintf(
2230 + 'wp.coreCommands.initializeCommandPalette({"is_network_admin":%s,"menu_commands":window.__openStationMenuCommands||[]});',
2231 + is_network_admin() ? 'true' : 'false'
2232 + );
2026 2233 }
2234 +
2027 2235 $out['scripts'][] = array(
2028 2236 'handle' => (string) $handle,
2029 2237 'url' => $payload['url'],
2030 2238 'before' => $payload['before'],
@@ -2113,9 +2321,9 @@
2113 2321 _doing_it_wrong(
2114 2322 esc_html( $function_name ),
2115 2323 sprintf(
2116 2324 /* translators: 1: kind ("Command"/"Settings-tab"/"Title-bar button"), 2: handle. */
2117 - 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' ),
2325 + 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' ),
2118 2326 esc_html( $kind ),
2119 2327 esc_html( $handle )
2120 2328 ),
2121 2329 '0.8.1'
@@ -2157,40 +2365,136 @@
2157 2365 openstation_warn_unresolvable_script_handle( '', '', '__flush__' );
2158 2366 }
2159 2367
2160 2368 /**
2161 - * Serialize the server-declared native-window registry into the
2162 - * payload shape the shell consumes. For each entry registered via
2163 - * `openstation_register_window()`, we capture: the window's
2164 - * metadata (id/title/icon/placement/dimensions/autofocus), the
2165 - * rendered template HTML (by running the template callback into an
2166 - * output buffer), and the URL of the enqueued script handle (so
2167 - * mid-session activations can load the plugin's JS dynamically
2168 - * without a full shell reload).
2369 + * Collect the native-window payload: slim per-window entries plus a
2370 + * handle-keyed script-data map.
2169 2371 *
2170 - * @return array[]
2372 + * For each entry registered via `openstation_register_window()` the
2373 + * `windows` list captures the window's metadata
2374 + * (id/title/icon/placement/dimensions/autofocus), the rendered
2375 + * template HTML, and the HANDLE NAMES of its script, companions and
2376 + * tab scripts. The resolved data those handles stand for — URL plus
2377 + * harvested `wp_localize_script` / `wp_add_inline_script` /
2378 + * translations, see `openstation_resolve_script_payload()` — lives
2379 + * ONCE per handle in `scriptData`, and the shell joins the two on
2380 + * receipt (`hydrateServerEntries()` in `src/native-windows.ts`).
2381 + * Each loadable handle's entry also names its dependency closure in
2382 + * `deps` (ordered handles, every one of them a key of the same map)
2383 + * so the lazy loader can bring a bundle's declared packages — and
2384 + * a src-less alias carrying its config — into the tab before it.
2385 + *
2386 + * The split exists because script data is a property of the HANDLE,
2387 + * not of the window: every App Framework window rides
2388 + * `openstation-app-runtime`, and inlining each entry's resolved copy
2389 + * serialized the same localize blobs and the same shared config set
2390 + * four times over — `scriptL10n` alone was ~100 KB of the boot
2391 + * payload, most of it repetition. The synthesized
2392 + * `openStationWindowConfig[ id ]` assignments group by handle for
2393 + * the same reason they used to ride every sharing entry: the shell
2394 + * fetches a URL once, and a bundle can serve one window from inside
2395 + * another (the Users window mounts the Profile form, which reads the
2396 + * user-edit config), so whichever entry loads the bundle must
2397 + * deliver the whole handle's config set.
2398 + *
2399 + * Style data stays inline on the entries — it never had a
2400 + * duplication problem worth a second map ( companion styles across
2401 + * the whole registry total ~2 KB ).
2402 + *
2403 + * @return array{windows:array[],scriptData:array<string,array{url:string,before:string[],after:string[],l10n:string[],translations:string,deps:string[]}>}
2171 2404 */
2172 -function openstation_build_native_windows_payload() {
2405 +function openstation_collect_native_windows_payload() {
2406 + $empty = array(
2407 + 'windows' => array(),
2408 + 'scriptData' => array(),
2409 + );
2173 2410 if ( ! function_exists( 'openstation_native_window_registry' ) ) {
2174 - return array();
2411 + return $empty;
2175 2412 }
2413 +
2176 2414 $registry = openstation_native_window_registry();
2177 2415 if ( ! is_array( $registry ) ) {
2178 - return array();
2416 + return $empty;
2179 2417 }
2180 2418
2419 + // A window says which admin offers it (`admin` in its registration:
2420 + // `site`, `network` or `any`). Every native window OpenStation
2421 + // ships is site-scoped, reading the current site's REST API, so in
2422 + // the network admin a `users.php` tile meaning "everyone on the
2423 + // network" would open one site's user list; those stay off the
2424 + // network shell. A window that declares `network` (the Network app)
2425 + // is offered there and nowhere else.
2426 + //
2427 + // Dropping the site windows there is also what disarms the
2428 + // client-side URL remaps: they match on the tail of a pathname
2429 + // (`endsWith( '/users.php' )`) and the network admin serves
2430 + // same-named files one directory down, but with nothing registered
2431 + // `openById()` finds no window and the remap falls through to the
2432 + // iframe.
2433 + $registry = array_filter( $registry, 'openstation_native_window_offered_here' );
2434 +
2435 + $script_data = array();
2436 +
2437 + // Handles resolved as a bundle to LOAD (a window's script, a
2438 + // companion, a tab) and what that visit answered — the handle, or
2439 + // '' for nothing to load — as opposed to reached only as
2440 + // somebody's dependency. A handle can be both — resolved as a
2441 + // dependency first, then named as a window's own script — and
2442 + // only the bundle visit computes its own closure.
2443 + $resolved_as_bundle = array();
2444 +
2445 + // Resolve a handle into the map, once. Returns the handle when it
2446 + // resolved to something loadable, '' when it did not (never
2447 + // registered, no src) — the same silent drop the inline shape
2448 + // applied to companions and tab scripts.
2449 + //
2450 + // The handle's dependency closure rides along as `deps`: an
2451 + // ordered handle list, each of which lands in the same map. A
2452 + // bundle delivered lazily never goes through WordPress's own
2453 + // dependency resolution — the loader injects one URL — so a
2454 + // window declaring `wp-api-fetch` found `wp.apiFetch` undefined,
2455 + // and one whose config rides a src-less alias handle (a common
2456 + // shape: `wp_register_script( $h, false )` plus
2457 + // `wp_add_inline_script()`, declared as the bundle's dependency
2458 + // so it always runs first) booted with no config at all. Anything
2459 + // the document already ran is skipped on the client, so a page
2460 + // that carried the packages anyway pays nothing.
2461 + $collect_handle = static function ( $handle ) use ( &$script_data, &$resolved_as_bundle ) {
2462 + $handle = (string) $handle;
2463 + if ( '' === $handle ) {
2464 + return '';
2465 + }
2466 + if ( isset( $resolved_as_bundle[ $handle ] ) ) {
2467 + return $resolved_as_bundle[ $handle ];
2468 + }
2469 + $payload = isset( $script_data[ $handle ] )
2470 + ? $script_data[ $handle ]
2471 + : openstation_resolve_script_payload( $handle );
2472 + if ( '' === $payload['url'] ) {
2473 + $resolved_as_bundle[ $handle ] = '';
2474 + return '';
2475 + }
2476 + $resolved_as_bundle[ $handle ] = $handle;
2477 + $deps = array();
2478 + foreach ( openstation_resolve_script_dependencies( $handle ) as $dep ) {
2479 + $dep_handle = (string) $dep['handle'];
2480 + unset( $dep['handle'] );
2481 + if ( ! isset( $script_data[ $dep_handle ] ) ) {
2482 + $dep['deps'] = array();
2483 + $script_data[ $dep_handle ] = $dep;
2484 + }
2485 + $deps[] = $dep_handle;
2486 + }
2487 + $payload['deps'] = $deps;
2488 + $script_data[ $handle ] = $payload;
2489 + return $handle;
2490 + };
2491 +
2181 2492 // Synthesized `openStationWindowConfig[ id ]` assignments, grouped
2182 - // by SCRIPT HANDLE rather than kept per window. Several windows
2183 - // share one bundle (Posts / Pages / Users / Profile all ride
2184 - // `os-posts-window`), and the shell fetches a URL once — so a
2185 - // config that only travels with its own window's entry is dropped
2186 - // for every sibling after the first, and a bundle whose code
2187 - // serves one window from inside another (the Users window mounts
2188 - // the Profile form, which reads the user-edit config) never sees
2189 - // it at all. Shipping the whole handle's config set on every entry
2190 - // that names the handle means whichever entry loads the bundle
2191 - // delivers all of them; the assignments are keyed by id, so
2192 - // replaying a sibling's copy is idempotent.
2493 + // by script handle (see the function docblock). Collected first so
2494 + // they can be appended to each handle's map entry exactly once,
2495 + // after its own harvested data — the same order the print pipeline
2496 + // would have used.
2193 2497 $config_snippets_by_handle = array();
2194 2498 foreach ( $registry as $entry ) {
2195 2499 $handle = isset( $entry['script'] ) ? (string) $entry['script'] : '';
2196 2500 if ( '' === $handle || ! is_callable( $entry['template'] ) ) {
@@ -2220,40 +2524,34 @@
2220 2524 // `<template>` at mid-session plugin activation without a
2221 2525 // reload.
2222 2526 $template_html = openstation_build_native_window_template_html( $entry );
2223 2527
2224 - // Resolve script handle → full payload (URL + harvested
2225 - // `extra` data) so the shell can inject a `<script>` tag
2226 - // dynamically on mid-session activation WITHOUT dropping
2227 - // `wp_localize_script` / `wp_add_inline_script` data the way
2228 - // the bare `<script src>` lazy-load path would. See
2229 - // `openstation_resolve_script_payload()` for shape.
2230 - $script_handle = isset( $entry['script'] ) ? (string) $entry['script'] : '';
2231 - $script_payload = openstation_resolve_script_payload( $script_handle );
2528 + // `$collect_handle()` answers "is there a bundle to fetch?", and
2529 + // returns '' when the handle resolves to no URL — a src-less
2530 + // alias handle registered only to carry `preload_script` or
2531 + // inline data, for instance. That is the right answer for
2532 + // `scriptHandle`, which names something to load. It is the
2533 + // wrong answer for `ownerHandle`, which names WHO the window
2534 + // belongs to: attribution does not depend on whether the owner
2535 + // happens to ship a file. Shipping '' there broke the
2536 + // documented "always populated" contract and blanked
2537 + // `wp.os.debug.window()`.
2538 + $declared_script = isset( $entry['script'] ) ? (string) $entry['script'] : '';
2539 + $script_handle = $collect_handle( $declared_script );
2540 + $owner_handle = '' !== $script_handle ? $script_handle : $declared_script;
2232 2541
2233 2542 // Companion handles (`scripts` arg) — bundles that extend the
2234 2543 // window from outside it and must be in the tab before its
2235 - // render callback paints. Same resolved shape as the main
2236 - // script, kept as a list so the shell loads them in the
2237 - // declared order ahead of it. Handles that resolve to nothing
2238 - // (never registered) are dropped rather than shipped as an
2239 - // entry the loader would skip anyway.
2544 + // render callback paints. Kept as an ordered handle list; the
2545 + // shell loads them in declared order ahead of the window's
2546 + // own script, resolving each through `scriptData`.
2240 2547 $companion_scripts = array();
2241 2548 if ( ! empty( $entry['scripts'] ) && is_array( $entry['scripts'] ) ) {
2242 2549 foreach ( $entry['scripts'] as $companion_handle ) {
2243 - $companion_handle = (string) $companion_handle;
2244 - $companion_payload = openstation_resolve_script_payload( $companion_handle );
2245 - if ( '' === $companion_payload['url'] ) {
2246 - continue;
2550 + $companion_handle = $collect_handle( $companion_handle );
2551 + if ( '' !== $companion_handle ) {
2552 + $companion_scripts[] = $companion_handle;
2247 2553 }
2248 - $companion_scripts[] = array(
2249 - 'scriptUrl' => $companion_payload['url'],
2250 - 'scriptHandle' => $companion_handle,
2251 - 'scriptBefore' => $companion_payload['before'],
2252 - 'scriptAfter' => $companion_payload['after'],
2253 - 'scriptL10n' => $companion_payload['l10n'],
2254 - 'scriptTranslations' => $companion_payload['translations'],
2255 - );
2256 2554 }
2257 2555 }
2258 2556
2259 2557 // Resolve the optional style handle alongside the script so the
@@ -2287,109 +2585,99 @@
2287 2585 );
2288 2586 }
2289 2587 }
2290 2588
2291 - // `config` arg on `openstation_register_window()` — discoverable
2292 - // alternative to `wp_localize_script`. We synthesize a localize
2293 - // snippet so it lands through the same delivery path as native
2294 - // `wp_localize_script`. The bundle reads
2295 - // `window.openStationWindowConfig[id]` (or via
2296 - // `wp.os.getWindowConfig(id)`).
2297 - //
2298 - // The whole HANDLE's config set rides along, own window first —
2299 - // see `$config_snippets_by_handle` above for why a shared
2300 - // bundle must carry its siblings' configs too.
2301 - if ( '' !== $script_handle && isset( $config_snippets_by_handle[ $script_handle ] ) ) {
2302 - $handle_snippets = $config_snippets_by_handle[ $script_handle ];
2303 - if ( isset( $handle_snippets[ $entry['id'] ] ) ) {
2304 - $script_payload['l10n'][] = $handle_snippets[ $entry['id'] ];
2305 - unset( $handle_snippets[ $entry['id'] ] );
2306 - }
2307 - foreach ( $handle_snippets as $sibling_snippet ) {
2308 - $script_payload['l10n'][] = $sibling_snippet;
2309 - }
2310 - } elseif ( '' === $script_handle ) {
2311 - // A window with no bundle keeps the old shape: its config
2312 - // snippet is synthesized onto the (never-delivered) script
2313 - // payload, preserving behavior for declarative windows.
2314 - $window_config = openstation_filter_native_window_config( $entry );
2315 - if ( ! empty( $window_config ) ) {
2316 - $script_payload['l10n'][] = sprintf(
2317 - 'window.openStationWindowConfig=window.openStationWindowConfig||{};window.openStationWindowConfig[%s]=%s;',
2318 - wp_json_encode( $entry['id'] ),
2319 - wp_json_encode( $window_config )
2320 - );
2321 - }
2322 - }
2323 -
2324 - // Tab metadata (label + extra script payloads) ships alongside
2325 - // the template so the shell can render a picker UI or load
2326 - // additional tab scripts when a tab's activation is late.
2589 + // Tab metadata ships alongside the template so the shell can
2590 + // render a picker UI, and each tab's script handle joins the
2591 + // map so a late tab activation can still load its bundle.
2327 2592 $tab_descriptors = array();
2328 2593 if ( function_exists( 'openstation_get_native_window_tabs' ) ) {
2329 2594 foreach ( openstation_get_native_window_tabs( $entry['id'] ) as $tab ) {
2330 - // The resolver returns the empty payload shape itself
2331 - // for an empty handle — no need to hand-write it here.
2332 - $tab_payload = openstation_resolve_script_payload( $tab['script'] );
2333 2595 $tab_descriptors[] = array(
2334 - 'value' => $tab['value'],
2335 - 'label' => $tab['label'],
2336 - 'isMain' => $tab['is_main'],
2337 - 'scriptUrl' => $tab_payload['url'],
2338 - 'scriptHandle' => $tab['script'],
2339 - 'scriptBefore' => $tab_payload['before'],
2340 - 'scriptAfter' => $tab_payload['after'],
2341 - 'scriptL10n' => $tab_payload['l10n'],
2342 - 'scriptTranslations' => $tab_payload['translations'],
2596 + 'value' => $tab['value'],
2597 + 'label' => $tab['label'],
2598 + 'isMain' => $tab['is_main'],
2599 + 'scriptHandle' => $collect_handle( $tab['script'] ),
2343 2600 );
2344 2601 }
2345 2602 }
2346 2603
2347 2604 $out[] = array(
2348 - 'id' => $entry['id'],
2349 - 'title' => $entry['title'],
2350 - 'icon' => $entry['icon'],
2351 - 'placement' => $entry['placement'],
2605 + 'id' => $entry['id'],
2606 + 'title' => $entry['title'],
2607 + 'icon' => $entry['icon'],
2608 + 'placement' => $entry['placement'],
2352 2609 // `'app'` or `'control'` — the navigation kind, which
2353 2610 // decides the launcher's default placement and its dock
2354 2611 // zone. See `src/nav/defaults.ts`.
2355 - 'navKind' => isset( $entry['nav_kind'] ) ? $entry['nav_kind'] : 'app',
2612 + 'navKind' => isset( $entry['nav_kind'] ) ? $entry['nav_kind'] : 'app',
2356 2613 // Sort key among system tiles. Absent / 0 puts a plugin's
2357 2614 // launcher ahead of the shell's own trailing cluster.
2358 - 'dockOrder' => isset( $entry['dock_order'] ) ? (int) $entry['dock_order'] : 0,
2359 - 'placeable' => ! empty( $entry['placeable'] ),
2360 - 'width' => $entry['width'],
2361 - 'height' => $entry['height'],
2362 - 'minWidth' => $entry['min_width'],
2363 - 'minHeight' => $entry['min_height'],
2364 - 'autofocus' => $entry['autofocus'],
2365 - 'templateId' => 'os-native-window-' . $entry['id'],
2366 - 'templateHtml' => $template_html,
2367 - 'scriptUrl' => $script_payload['url'],
2368 - 'scriptHandle' => $script_handle,
2369 - 'ownerHandle' => $script_handle,
2370 - 'scriptBefore' => $script_payload['before'],
2371 - 'scriptAfter' => $script_payload['after'],
2372 - 'scriptL10n' => $script_payload['l10n'],
2373 - 'scriptTranslations' => $script_payload['translations'],
2374 - 'companionScripts' => $companion_scripts,
2615 + 'dockOrder' => isset( $entry['dock_order'] ) ? (int) $entry['dock_order'] : 0,
2616 + 'placeable' => ! empty( $entry['placeable'] ),
2617 + 'width' => $entry['width'],
2618 + 'height' => $entry['height'],
2619 + 'minWidth' => $entry['min_width'],
2620 + 'minHeight' => $entry['min_height'],
2621 + 'autofocus' => $entry['autofocus'],
2622 + 'templateId' => 'os-native-window-' . $entry['id'],
2623 + 'templateHtml' => $template_html,
2624 + 'scriptHandle' => $script_handle,
2625 + 'ownerHandle' => $owner_handle,
2626 + 'companionScripts' => $companion_scripts,
2375 2627 // Whether the shell loads the bundle at boot rather than on
2376 2628 // first open. Off by default: a window's script is dead
2377 2629 // weight on every admin page until the window is actually
2378 2630 // opened.
2379 - 'preloadScript' => ! empty( $entry['preload_script'] ),
2380 - 'styleUrl' => $style_payload['url'],
2381 - 'styleHandle' => $style_handle,
2382 - 'styleInline' => $style_payload['inline'],
2383 - 'companionStyles' => $companion_styles,
2384 - 'tabs' => $tab_descriptors,
2631 + 'preloadScript' => ! empty( $entry['preload_script'] ),
2632 + 'styleUrl' => $style_payload['url'],
2633 + 'styleHandle' => $style_handle,
2634 + 'styleInline' => $style_payload['inline'],
2635 + 'companionStyles' => $companion_styles,
2636 + 'tabs' => $tab_descriptors,
2385 2637 );
2386 2638 }
2387 2639
2388 - return $out;
2640 + // Append each handle's synthesized config set to its map entry —
2641 + // once, after the handle's own harvested data. The snippets land
2642 + // in REGISTRY-ITERATION order for every consumer of the handle;
2643 + // the old per-entry shape put each window's own config first, an
2644 + // ordering nothing could observe (each snippet assigns a distinct
2645 + // `openStationWindowConfig[ id ]` key and none reads another), so
2646 + // it is deliberately not preserved. Configs for handles that
2647 + // resolved to nothing are undeliverable and drop, exactly as they
2648 + // always did.
2649 + foreach ( $config_snippets_by_handle as $handle => $snippets ) {
2650 + if ( ! isset( $script_data[ $handle ] ) ) {
2651 + continue;
2652 + }
2653 + foreach ( $snippets as $snippet ) {
2654 + $script_data[ $handle ]['l10n'][] = $snippet;
2655 + }
2656 + }
2657 +
2658 + return array(
2659 + 'windows' => $out,
2660 + 'scriptData' => $script_data,
2661 + );
2389 2662 }
2390 2663
2391 2664 /**
2665 + * The `windows` half of {@see openstation_collect_native_windows_payload()}.
2666 + *
2667 + * Kept as the historical entry point — tests and older call sites
2668 + * ask for the entry list alone. Anything that also needs the
2669 + * script-data map (everything that actually LOADS a bundle) should
2670 + * call the collector and take both halves from one build.
2671 + *
2672 + * @return array[]
2673 + */
2674 +function openstation_build_native_windows_payload() {
2675 + $bundle = openstation_collect_native_windows_payload();
2676 + return $bundle['windows'];
2677 +}
2678 +
2679 +/**
2392 2680 * Cleans a `$menu` / `$submenu` title for display.
2393 2681 *
2394 2682 * Strips badge spans first (`<span class="update-plugins count-3">`),
2395 2683 * then any remaining markup. An empty result means the entry has no
@@ -2447,13 +2735,45 @@
2447 2735 return file_exists( ABSPATH . 'wp-admin/' . $file );
2448 2736 }
2449 2737
2450 2738 /**
2739 + * The admin URL a menu slug resolves against.
2740 + *
2741 + * Follows the admin the request is in: the network admin's own URL there,
2742 + * because its globals carry network slugs (`sites.php`, `settings.php`)
2743 + * that exist only under `wp-admin/network/`, and the site admin's
2744 + * everywhere else.
2745 + *
2746 + * The same answer `self_admin_url()` gives, without its filter. That
2747 + * filter receives the path, so a host can use it to send one screen
2748 + * somewhere else, and WordPress.com points `plugin-install.php` at its own
2749 + * installer. Resolved through it, the wp-admin original of a menu row the
2750 + * host replaced reads as off-site, and the dock drops it along with the
2751 + * replacement, which is how Plugins > Add Plugin disappears there.
2752 + *
2753 + * @param string $path Optional. Path relative to the admin URL.
2754 + * @return string Absolute admin URL.
2755 + */
2756 +function openstation_menu_admin_url( $path = '' ) {
2757 + if ( is_network_admin() ) {
2758 + return network_admin_url( $path );
2759 + }
2760 + if ( is_user_admin() ) {
2761 + return user_admin_url( $path );
2762 + }
2763 + return admin_url( $path );
2764 +}
2765 +
2766 +/**
2451 2767 * Converts a menu item slug to a full admin URL.
2452 2768 *
2769 + * Resolution goes through {@see openstation_menu_admin_url()}, which
2770 + * follows the admin the request is in without passing through the
2771 + * filterable `self_admin_url()`.
2772 + *
2453 2773 * Handles three slug shapes:
2454 2774 * 1. Direct file references (`edit.php`, `upload.php`) — passed
2455 - * through `admin_url()` as-is.
2775 + * through `openstation_menu_admin_url()` as-is.
2456 2776 * 2. Plain plugin page slugs (`my-plugin`) — routed through
2457 2777 * `admin.php?page=<slug>` with the slug `rawurlencode()`d.
2458 2778 * 3. Plugin page slugs that embed extra query parameters
2459 2779 * (`wc-admin&path=/customers`) — split on the first `&`, the
@@ -2514,9 +2834,9 @@
2514 2834 if (
2515 2835 false !== strpos( $slug, '.php' ) &&
2516 2836 ( ! isset( $_parent_pages[ $slug ] ) || openstation_is_admin_file_slug( $slug ) )
2517 2837 ) {
2518 - return esc_url_raw( admin_url( $slug ) );
2838 + return esc_url_raw( openstation_menu_admin_url( $slug ) );
2519 2839 }
2520 2840
2521 2841 // Plugin page slug with embedded query parameters
2522 2842 // (e.g., 'wc-admin&path=/customers'). Split the page slug from
@@ -2556,9 +2876,9 @@
2556 2876 $host = add_query_arg( 'page', $slug, $parent_slug );
2557 2877 }
2558 2878 }
2559 2879
2560 - $url = admin_url( $host );
2880 + $url = openstation_menu_admin_url( $host );
2561 2881 if ( ! empty( $extra_args ) ) {
2562 2882 $url = add_query_arg( $extra_args, $url );
2563 2883 }
2564 2884 return esc_url_raw( $url );