PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.13
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.13
1.1.13 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 All 37 releases
← All changes | includes/core/payload.php +355 -52 1.1.5 → 1.1.13 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 /**
@@ -971,9 +1027,9 @@
971 1027 if ( ! is_string( $file ) || '' === $file ) {
972 1028 return null;
973 1029 }
974 1030 $plugins_dir = wp_normalize_path( WP_PLUGIN_DIR );
975 - $norm = wp_normalize_path( $file );
1031 + $norm = openstation_plugin_link_path( wp_normalize_path( $file ) );
976 1032 if ( 0 !== strpos( $norm, $plugins_dir . '/' ) ) {
977 1033 return null;
978 1034 }
979 1035 if ( ! function_exists( 'get_plugins' ) ) {
@@ -1442,8 +1498,9 @@
1442 1498 $norm = wp_normalize_path( (string) $frame['file'] );
1443 1499 if ( '' !== $self_dir && 0 === strpos( $norm, $self_dir ) ) {
1444 1500 continue;
1445 1501 }
1502 + $norm = openstation_plugin_link_path( $norm );
1446 1503 foreach ( $dirs as $dir ) {
1447 1504 if ( 0 === strpos( $norm, $dir ) ) {
1448 1505 return $norm;
1449 1506 }
@@ -1451,8 +1508,36 @@
1451 1508 }
1452 1509 return null;
1453 1510 }
1454 1511
1512 +/**
1513 + * Map a path PHP reported back to the plugin folder WordPress loaded
1514 + * it through.
1515 + *
1516 + * PHP resolves symlinks in file names, so a plugin folder linked into
1517 + * `plugins/` from elsewhere shows up in backtraces and reflection outside
1518 + * `WP_PLUGIN_DIR`. WordPress.com's managed plugins are linked that way:
1519 + * `plugins/woocommerce` resolves to `/wordpress/plugins/woocommerce/<version>/`.
1520 + * Core records each active plugin's link and target in
1521 + * `$wp_plugin_paths` before loading it, and `plugin_basename()` maps
1522 + * through the same list.
1523 + *
1524 + * @param string $path Normalized absolute path.
1525 + * @return string The same file under the plugin's link, or `$path`
1526 + * when no active plugin is linked from there.
1527 + */
1528 +function openstation_plugin_link_path( $path ) {
1529 + global $wp_plugin_paths;
1530 +
1531 + foreach ( (array) $wp_plugin_paths as $link => $target ) {
1532 + $target = trailingslashit( (string) $target );
1533 + if ( 0 === strpos( $path, $target ) ) {
1534 + return trailingslashit( (string) $link ) . substr( $path, strlen( $target ) );
1535 + }
1536 + }
1537 + return $path;
1538 +}
1539 +
1455 1540 add_action(
1456 1541 'registered_post_type',
1457 1542 static function ( $post_type ) {
1458 1543 openstation_record_type_registrant( $post_type, 'post_type' );
@@ -1650,8 +1735,15 @@
1650 1735 'url' => network_admin_url( 'update-core.php' ),
1651 1736 );
1652 1737 }
1653 1738
1739 + // The site switcher's rows: on a network, the instances this shell
1740 + // may switch to (`openstation_multisite_payload()`), null elsewhere.
1741 + // The Network app spends a menu refresh after every action that
1742 + // changes them (add, remove, join, leave, sync), so the row above
1743 + // overview's desktop tiles follows the registry without a reload.
1744 + $payload['multisite'] = openstation_multisite_payload();
1745 +
1654 1746 // A cheap structural fingerprint of the admin menu the shell uses to
1655 1747 // decide whether a live refresh is warranted. Shipped in every full
1656 1748 // payload so the shell can seed / update its last-known signature
1657 1749 // without recomputing it client-side (which would risk drift from
@@ -1658,8 +1750,13 @@
1658 1750 // the server's capability-gated view). See
1659 1751 // openstation_menu_signature().
1660 1752 $payload['menuSig'] = openstation_menu_signature();
1661 1753
1754 + // Each script dependency's payload once, entries carry handles.
1755 + $script_dep_payloads = array();
1756 + $payload = openstation_compact_script_deps( $payload, $script_dep_payloads );
1757 + $payload['scriptDepPayloads'] = (object) $script_dep_payloads;
1758 +
1662 1759 return $payload;
1663 1760 }
1664 1761
1665 1762 /**
@@ -1853,8 +1950,12 @@
1853 1950 if ( $dep_handle === $handle ) {
1854 1951 continue;
1855 1952 }
1856 1953 $payload = openstation_resolve_script_payload( $dep_handle );
1954 + // An alias (no `src`) stays in the list when it carries inline
1955 + // data — that data is the whole reason it was declared, and a
1956 + // plugin's config blob commonly rides one. Nothing to fetch
1957 + // AND nothing to run is the only thing dropped.
1857 1958 if ( '' === $payload['url']
1858 1959 && empty( $payload['before'] )
1859 1960 && empty( $payload['after'] )
1860 1961 && empty( $payload['l10n'] ) ) {
@@ -1859,8 +1960,17 @@
1859 1960 && empty( $payload['after'] )
1860 1961 && empty( $payload['l10n'] ) ) {
1861 1962 continue;
1862 1963 }
1964 + // The handle rides along because the shell needs it to decide
1965 + // whether the page already has this package. A URL is not
1966 + // enough: with Core's script concatenation on — the wp-admin
1967 + // default — every package below `wp-includes/js/` is served
1968 + // from one `load-scripts.php` blob and has no `<script src>`
1969 + // of its own to match against. Re-running `wp-hooks` because
1970 + // we could not see it replaces `window.wp.hooks`, and every
1971 + // subscriber registered at boot goes deaf. See
1972 + // `src/script-presence.ts`.
1863 1973 $payload['handle'] = (string) $dep_handle;
1864 1974 $out[] = $payload;
1865 1975 }
1866 1976 return $out;
@@ -1866,8 +1976,102 @@
1866 1976 return $out;
1867 1977 }
1868 1978
1869 1979 /**
1980 + * Move every entry's `scriptDeps` payloads into one map.
1981 + *
1982 + * {@see openstation_resolve_script_dependencies()} returns the full
1983 + * payload of each dependency (URL, l10n, before/after), and ~20 entry
1984 + * builders call it. A dependency shared by N entries was therefore
1985 + * serialized N times: one plugin's 5.5 KB localized object became
1986 + * ~400 KB of a 489 KB `openStationConfig` (GH#892). This replaces each
1987 + * entry's `scriptDeps` list with its handles and puts each handle's
1988 + * payload in `$map` once. The shell resolves the handles back before
1989 + * any consumer reads them; see `src/script-dep-payloads.ts`.
1990 + *
1991 + * ENTRY DEPTH ONLY. `$payload` is a payload whose top-level values are
1992 + * entry lists (`serverWidgets`, `serverCommandScripts`, ...), and only
1993 + * a `scriptDeps` sitting directly on one of those entries is touched.
1994 + * The key is the shell's there. Deeper down it is a plugin's metadata
1995 + * (`settings => [ 'scriptDeps' => ... ]`), and rewriting it would hoist
1996 + * foreign data into the map first-wins and hydrate every real
1997 + * dependency of that handle to it. Scoping by depth rather than by a
1998 + * list of keys means a new builder is covered without an edit here.
1999 + * It also leaves every other branch of the payload unassigned, so
2000 + * PHP's copy-on-write never has to copy them.
2001 + *
2002 + * Every string left in a compacted list has an entry in `$map`: a bare
2003 + * handle a builder (or a filter on one) emitted is resolved here, by
2004 + * the same rule as {@see openstation_resolve_script_dependencies()}.
2005 + * A handle with nothing to fetch and nothing to run is dropped, as it
2006 + * is there. The client can then treat a string with no map entry as a
2007 + * payload from somewhere else, not a dependency this side dropped.
2008 + *
2009 + * Runs on the finished payload, so every builder, and every filter on
2010 + * a builder's output, still sees the full shape.
2011 + *
2012 + * @param array $payload Payload whose top-level values are entry lists.
2013 + * @param array $map Handle => dependency payload, filled in place.
2014 + * @return array The payload with each entry's `scriptDeps` reduced to handles.
2015 + */
2016 +function openstation_compact_script_deps( $payload, array &$map ) {
2017 + if ( ! is_array( $payload ) ) {
2018 + return $payload;
2019 + }
2020 + foreach ( $payload as $list_key => $entries ) {
2021 + if ( ! is_array( $entries ) ) {
2022 + continue;
2023 + }
2024 + foreach ( $entries as $entry_key => $entry ) {
2025 + if ( ! is_array( $entry ) || ! isset( $entry['scriptDeps'] ) || ! is_array( $entry['scriptDeps'] ) ) {
2026 + continue;
2027 + }
2028 + $payload[ $list_key ][ $entry_key ]['scriptDeps'] = openstation_compact_script_dep_list( $entry['scriptDeps'], $map );
2029 + }
2030 + }
2031 + return $payload;
2032 +}
2033 +
2034 +/**
2035 + * One entry's `scriptDeps` list, reduced to handles.
2036 + *
2037 + * @param array $deps Dependency payloads and/or bare handles.
2038 + * @param array $map Handle => dependency payload, filled in place.
2039 + * @return array Handles, in order; anything unkeyable passes through.
2040 + */
2041 +function openstation_compact_script_dep_list( array $deps, array &$map ) {
2042 + $handles = array();
2043 + foreach ( $deps as $dep ) {
2044 + if ( is_string( $dep ) && '' !== $dep ) {
2045 + if ( ! isset( $map[ $dep ] ) ) {
2046 + $payload = openstation_resolve_script_payload( $dep );
2047 + if ( '' === $payload['url']
2048 + && empty( $payload['before'] )
2049 + && empty( $payload['after'] )
2050 + && empty( $payload['l10n'] ) ) {
2051 + continue;
2052 + }
2053 + $payload['handle'] = $dep;
2054 + $map[ $dep ] = $payload;
2055 + }
2056 + $handles[] = $dep;
2057 + continue;
2058 + }
2059 + if ( is_array( $dep ) && isset( $dep['handle'] ) && '' !== (string) $dep['handle'] ) {
2060 + $handle = (string) $dep['handle'];
2061 + if ( ! isset( $map[ $handle ] ) ) {
2062 + $map[ $handle ] = $dep;
2063 + }
2064 + $handles[] = $handle;
2065 + continue;
2066 + }
2067 + // A handle-less payload has nothing to key it by.
2068 + $handles[] = $dep;
2069 + }
2070 + return $handles;
2071 +}
2072 +
2073 +/**
1870 2074 * Resolve a registered WP script handle into the full payload the
1871 2075 * shell needs to lazy-load it without going through `wp_print_scripts()`.
1872 2076 *
1873 2077 * Returns:
@@ -1891,10 +2095,14 @@
1891 2095 * around the lazy `<script src>` in the same order
1892 2096 * `WP_Scripts::do_item()` would have used.
1893 2097 *
1894 2098 * Returns an empty payload (`array( 'url' => '' )`) when the handle
1895 - * is unregistered or has no source — callers treat that as "no
1896 - * script to load."
2099 + * is unregistered. A registered handle with no source — an alias
2100 + * carrying only inline data — also comes back with an empty `url`,
2101 + * but its `before` / `after` / `l10n` are kept: callers that load a
2102 + * bundle treat an empty `url` as "nothing to fetch", and the
2103 + * dependency walk ({@see openstation_resolve_script_dependencies()})
2104 + * still replays what the alias would have printed.
1897 2105 *
1898 2106 * Shared between `openstation_register_window()` and
1899 2107 * `openstation_register_widget()` (and every other registration that
1900 2108 * relies on lazy script loading in the shell) because all of them
@@ -1922,20 +2130,31 @@
1922 2130 return $empty;
1923 2131 }
1924 2132 $registered = $wp_scripts->registered[ $handle ];
1925 2133 $src = is_string( $registered->src ) ? $registered->src : '';
1926 - if ( '' === $src ) {
1927 - return $empty;
1928 - }
1929 2134
1930 - // Normalize relative paths + attach cache-bust ver.
1931 - $resolved = $src;
1932 - if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) {
1933 - $resolved = site_url( $resolved );
2135 + // A handle with no `src` is an ALIAS — WordPress's supported way
2136 + // to ship inline-only JavaScript (`wp_register_script( $h, false )`
2137 + // plus `wp_add_inline_script()`), and a common home for a plugin's
2138 + // config blob: registering it as a *dependency* of every bundle is
2139 + // what guarantees the config runs first, whatever the enqueue
2140 + // order. `WP_Scripts::do_item()` prints an alias's localized data
2141 + // and its before/after snippets and returns before the `<script
2142 + // src>` it does not have. The payload mirrors that: `url` stays
2143 + // empty (there is nothing to fetch) and the inline data is kept,
2144 + // so a dependency walk can replay it. Translations are not: Core
2145 + // only prints those for a handle it printed a tag for.
2146 + $resolved = '';
2147 + if ( '' !== $src ) {
2148 + // Normalize relative paths + attach cache-bust ver.
2149 + $resolved = $src;
2150 + if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) {
2151 + $resolved = site_url( $resolved );
2152 + }
2153 + if ( ! empty( $registered->ver ) ) {
2154 + $resolved = add_query_arg( 'ver', $registered->ver, $resolved );
2155 + }
1934 2156 }
1935 - if ( ! empty( $registered->ver ) ) {
1936 - $resolved = add_query_arg( 'ver', $registered->ver, $resolved );
1937 - }
1938 2157
1939 2158 // Harvest `extra` data the lazy-load path would otherwise drop.
1940 2159 $before = array();
1941 2160 $after = array();
@@ -1970,9 +2189,9 @@
1970 2189 // `wp.i18n.setLocaleData( JSON, 'domain' )` snippet that the print
1971 2190 // pipeline emits before the script body. `print_translations(
1972 2191 // $handle, false )` returns the snippet without echoing.
1973 2192 $translations = '';
1974 - if ( method_exists( $wp_scripts, 'print_translations' ) ) {
2193 + if ( '' !== $resolved && method_exists( $wp_scripts, 'print_translations' ) ) {
1975 2194 $captured = $wp_scripts->print_translations( $handle, false );
1976 2195 if ( is_string( $captured ) ) {
1977 2196 $translations = $captured;
1978 2197 }
@@ -2083,12 +2302,15 @@
2083 2302 * time the palette is invoked (`src/commands/palette-assets.ts`).
2084 2303 *
2085 2304 * Handles with no `src` (pure aggregators) are kept whenever they
2086 2305 * carry inline data; dropping them would lose middleware and locale
2087 - * setup the chain depends on. Handles another plugin already
2088 - * enqueued at boot print normally and are skipped client-side by a
2089 - * same-path DOM sniff — the manifest deliberately lists them anyway,
2090 - * because which ones those are differs per site and per screen.
2306 + * setup the chain depends on. Handles the boot page already printed
2307 + * are skipped client-side, by handle as well as by path so that a
2308 + * package Core concatenated into `load-scripts.php` is recognized
2309 + * (`src/script-presence.ts`) — the manifest deliberately lists them
2310 + * anyway, because which ones those are differs per site and per
2311 + * screen. Each entry therefore carries its `handle`, and that is
2312 + * load-bearing rather than informational.
2091 2313 *
2092 2314 * Returns `null` on pre-6.9 sites (no Core palette to defer).
2093 2315 *
2094 2316 * @return array{scripts:array<int,array<string,mixed>>,styles:array<int,array<string,mixed>>}|null
@@ -2143,24 +2365,16 @@
2143 2365 $script_probe->done = array();
2144 2366 $script_probe->all_deps( $script_roots );
2145 2367 foreach ( $script_probe->to_do as $handle ) {
2146 2368 $payload = openstation_resolve_script_payload( $handle );
2147 - if ( '' === $payload['url'] ) {
2148 - // Src-less aggregator — keep it only for its inline data.
2149 - $registered = isset( $scripts->registered[ $handle ] ) ? $scripts->registered[ $handle ] : null;
2150 - if ( $registered ) {
2151 - foreach ( array( 'before', 'after' ) as $position ) {
2152 - if ( isset( $registered->extra[ $position ] ) && is_array( $registered->extra[ $position ] ) ) {
2153 - $payload[ $position ] = array_values( array_filter( array_map( 'strval', $registered->extra[ $position ] ) ) );
2154 - }
2155 - }
2156 - if ( ! empty( $registered->extra['data'] ) && is_string( $registered->extra['data'] ) ) {
2157 - $payload['l10n'][] = $registered->extra['data'];
2158 - }
2159 - }
2160 - if ( empty( $payload['before'] ) && empty( $payload['after'] ) && empty( $payload['l10n'] ) ) {
2161 - continue;
2162 - }
2369 + // A src-less aggregator is kept only for its inline data — the
2370 + // resolver harvests that for an alias — and dropped when it
2371 + // carries none.
2372 + if ( '' === $payload['url']
2373 + && empty( $payload['before'] )
2374 + && empty( $payload['after'] )
2375 + && empty( $payload['l10n'] ) ) {
2376 + continue;
2163 2377 }
2164 2378 // Core's `initializeCommandPalette( {…} )` inline embeds the
2165 2379 // serialized admin-menu command list — ~20 KB that the boot
2166 2380 // page ALREADY carries as `window.__openStationMenuCommands`
@@ -2278,9 +2492,9 @@
2278 2492 _doing_it_wrong(
2279 2493 esc_html( $function_name ),
2280 2494 sprintf(
2281 2495 /* translators: 1: kind ("Command"/"Settings-tab"/"Title-bar button"), 2: handle. */
2282 - 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' ),
2496 + 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' ),
2283 2497 esc_html( $kind ),
2284 2498 esc_html( $handle )
2285 2499 ),
2286 2500 '0.8.1'
@@ -2334,12 +2548,16 @@
2334 2548 * harvested `wp_localize_script` / `wp_add_inline_script` /
2335 2549 * translations, see `openstation_resolve_script_payload()` — lives
2336 2550 * ONCE per handle in `scriptData`, and the shell joins the two on
2337 2551 * receipt (`hydrateServerEntries()` in `src/native-windows.ts`).
2552 + * Each loadable handle's entry also names its dependency closure in
2553 + * `deps` (ordered handles, every one of them a key of the same map)
2554 + * so the lazy loader can bring a bundle's declared packages — and
2555 + * a src-less alias carrying its config — into the tab before it.
2338 2556 *
2339 2557 * The split exists because script data is a property of the HANDLE,
2340 - * not of the window: Posts, Pages, Users and Profile all ride
2341 - * `os-posts-window`, and inlining each entry's resolved copy
2558 + * not of the window: every App Framework window rides
2559 + * `openstation-app-runtime`, and inlining each entry's resolved copy
2342 2560 * serialized the same localize blobs and the same shared config set
2343 2561 * four times over — `scriptL10n` alone was ~100 KB of the boot
2344 2562 * payload, most of it repetition. The synthesized
2345 2563 * `openStationWindowConfig[ id ]` assignments group by handle for
@@ -2352,9 +2570,9 @@
2352 2570 * Style data stays inline on the entries — it never had a
2353 2571 * duplication problem worth a second map ( companion styles across
2354 2572 * the whole registry total ~2 KB ).
2355 2573 *
2356 - * @return array{windows:array[],scriptData:array<string,array{url:string,before:string[],after:string[],l10n:string[],translations:string}>}
2574 + * @return array{windows:array[],scriptData:array<string,array{url:string,before:string[],after:string[],l10n:string[],translations:string,deps:string[]}>}
2357 2575 */
2358 2576 function openstation_collect_native_windows_payload() {
2359 2577 $empty = array(
2360 2578 'windows' => array(),
@@ -2362,31 +2580,83 @@
2362 2580 );
2363 2581 if ( ! function_exists( 'openstation_native_window_registry' ) ) {
2364 2582 return $empty;
2365 2583 }
2584 +
2366 2585 $registry = openstation_native_window_registry();
2367 2586 if ( ! is_array( $registry ) ) {
2368 2587 return $empty;
2369 2588 }
2370 2589
2590 + // A window says which admin offers it (`admin` in its registration:
2591 + // `site`, `network` or `any`). Every native window OpenStation
2592 + // ships is site-scoped, reading the current site's REST API, so in
2593 + // the network admin a `users.php` tile meaning "everyone on the
2594 + // network" would open one site's user list; those stay off the
2595 + // network shell. A window that declares `network` (the Network app)
2596 + // is offered there and nowhere else.
2597 + //
2598 + // Dropping the site windows there is also what disarms the
2599 + // client-side URL remaps: they match on the tail of a pathname
2600 + // (`endsWith( '/users.php' )`) and the network admin serves
2601 + // same-named files one directory down, but with nothing registered
2602 + // `openById()` finds no window and the remap falls through to the
2603 + // iframe.
2604 + $registry = array_filter( $registry, 'openstation_native_window_offered_here' );
2605 +
2371 2606 $script_data = array();
2372 2607
2608 + // Handles resolved as a bundle to LOAD (a window's script, a
2609 + // companion, a tab) and what that visit answered — the handle, or
2610 + // '' for nothing to load — as opposed to reached only as
2611 + // somebody's dependency. A handle can be both — resolved as a
2612 + // dependency first, then named as a window's own script — and
2613 + // only the bundle visit computes its own closure.
2614 + $resolved_as_bundle = array();
2615 +
2373 2616 // Resolve a handle into the map, once. Returns the handle when it
2374 2617 // resolved to something loadable, '' when it did not (never
2375 2618 // registered, no src) — the same silent drop the inline shape
2376 2619 // applied to companions and tab scripts.
2377 - $collect_handle = static function ( $handle ) use ( &$script_data ) {
2620 + //
2621 + // The handle's dependency closure rides along as `deps`: an
2622 + // ordered handle list, each of which lands in the same map. A
2623 + // bundle delivered lazily never goes through WordPress's own
2624 + // dependency resolution — the loader injects one URL — so a
2625 + // window declaring `wp-api-fetch` found `wp.apiFetch` undefined,
2626 + // and one whose config rides a src-less alias handle (a common
2627 + // shape: `wp_register_script( $h, false )` plus
2628 + // `wp_add_inline_script()`, declared as the bundle's dependency
2629 + // so it always runs first) booted with no config at all. Anything
2630 + // the document already ran is skipped on the client, so a page
2631 + // that carried the packages anyway pays nothing.
2632 + $collect_handle = static function ( $handle ) use ( &$script_data, &$resolved_as_bundle ) {
2378 2633 $handle = (string) $handle;
2379 2634 if ( '' === $handle ) {
2380 2635 return '';
2381 2636 }
2382 - if ( isset( $script_data[ $handle ] ) ) {
2383 - return $handle;
2637 + if ( isset( $resolved_as_bundle[ $handle ] ) ) {
2638 + return $resolved_as_bundle[ $handle ];
2384 2639 }
2385 - $payload = openstation_resolve_script_payload( $handle );
2640 + $payload = isset( $script_data[ $handle ] )
2641 + ? $script_data[ $handle ]
2642 + : openstation_resolve_script_payload( $handle );
2386 2643 if ( '' === $payload['url'] ) {
2644 + $resolved_as_bundle[ $handle ] = '';
2387 2645 return '';
2388 2646 }
2647 + $resolved_as_bundle[ $handle ] = $handle;
2648 + $deps = array();
2649 + foreach ( openstation_resolve_script_dependencies( $handle ) as $dep ) {
2650 + $dep_handle = (string) $dep['handle'];
2651 + unset( $dep['handle'] );
2652 + if ( ! isset( $script_data[ $dep_handle ] ) ) {
2653 + $dep['deps'] = array();
2654 + $script_data[ $dep_handle ] = $dep;
2655 + }
2656 + $deps[] = $dep_handle;
2657 + }
2658 + $payload['deps'] = $deps;
2389 2659 $script_data[ $handle ] = $payload;
2390 2660 return $handle;
2391 2661 };
2392 2662
@@ -2534,8 +2804,9 @@
2534 2804 'styleHandle' => $style_handle,
2535 2805 'styleInline' => $style_payload['inline'],
2536 2806 'companionStyles' => $companion_styles,
2537 2807 'tabs' => $tab_descriptors,
2808 + 'menuPages' => isset( $entry['menu_pages'] ) ? array_values( (array) $entry['menu_pages'] ) : array(),
2538 2809 );
2539 2810 }
2540 2811
2541 2812 // Append each handle's synthesized config set to its map entry —
@@ -2636,13 +2907,45 @@
2636 2907 return file_exists( ABSPATH . 'wp-admin/' . $file );
2637 2908 }
2638 2909
2639 2910 /**
2911 + * The admin URL a menu slug resolves against.
2912 + *
2913 + * Follows the admin the request is in: the network admin's own URL there,
2914 + * because its globals carry network slugs (`sites.php`, `settings.php`)
2915 + * that exist only under `wp-admin/network/`, and the site admin's
2916 + * everywhere else.
2917 + *
2918 + * The same answer `self_admin_url()` gives, without its filter. That
2919 + * filter receives the path, so a host can use it to send one screen
2920 + * somewhere else, and WordPress.com points `plugin-install.php` at its own
2921 + * installer. Resolved through it, the wp-admin original of a menu row the
2922 + * host replaced reads as off-site, and the dock drops it along with the
2923 + * replacement, which is how Plugins > Add Plugin disappears there.
2924 + *
2925 + * @param string $path Optional. Path relative to the admin URL.
2926 + * @return string Absolute admin URL.
2927 + */
2928 +function openstation_menu_admin_url( $path = '' ) {
2929 + if ( is_network_admin() ) {
2930 + return network_admin_url( $path );
2931 + }
2932 + if ( is_user_admin() ) {
2933 + return user_admin_url( $path );
2934 + }
2935 + return admin_url( $path );
2936 +}
2937 +
2938 +/**
2640 2939 * Converts a menu item slug to a full admin URL.
2641 2940 *
2941 + * Resolution goes through {@see openstation_menu_admin_url()}, which
2942 + * follows the admin the request is in without passing through the
2943 + * filterable `self_admin_url()`.
2944 + *
2642 2945 * Handles three slug shapes:
2643 2946 * 1. Direct file references (`edit.php`, `upload.php`) — passed
2644 - * through `admin_url()` as-is.
2947 + * through `openstation_menu_admin_url()` as-is.
2645 2948 * 2. Plain plugin page slugs (`my-plugin`) — routed through
2646 2949 * `admin.php?page=<slug>` with the slug `rawurlencode()`d.
2647 2950 * 3. Plugin page slugs that embed extra query parameters
2648 2951 * (`wc-admin&path=/customers`) — split on the first `&`, the
@@ -2703,9 +3006,9 @@
2703 3006 if (
2704 3007 false !== strpos( $slug, '.php' ) &&
2705 3008 ( ! isset( $_parent_pages[ $slug ] ) || openstation_is_admin_file_slug( $slug ) )
2706 3009 ) {
2707 - return esc_url_raw( admin_url( $slug ) );
3010 + return esc_url_raw( openstation_menu_admin_url( $slug ) );
2708 3011 }
2709 3012
2710 3013 // Plugin page slug with embedded query parameters
2711 3014 // (e.g., 'wc-admin&path=/customers'). Split the page slug from
@@ -2745,9 +3048,9 @@
2745 3048 $host = add_query_arg( 'page', $slug, $parent_slug );
2746 3049 }
2747 3050 }
2748 3051
2749 - $url = admin_url( $host );
3052 + $url = openstation_menu_admin_url( $host );
2750 3053 if ( ! empty( $extra_args ) ) {
2751 3054 $url = add_query_arg( $extra_args, $url );
2752 3055 }
2753 3056 return esc_url_raw( $url );