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
desktop-mode / includes / core / payload.php

payload.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.13, at includes/core/payload.php

3,058 lines 118.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — payload building helpers.
4 *
5 * Dock-item construction, native-window payload assembly, menu
6 * payload (the data the shell shows in the dock + on bootstrap),
7 * and the script/style handle resolvers used by the live-refresh
8 * and lazy-load paths.
9 *
10 * Extracted from the 1,609-LOC `helpers.php` during the
11 * architecture-0.8.1 PHP slicing (phase 6). Behaviour is
12 * unchanged: every function name is identical and every WP filter
13 * still fires with the same shape — PHP looks function references
14 * up by name at hook-fire time, so existing callers continue to
15 * resolve regardless of which file owns the definition.
16 *
17 * @package OpenStation
18 */
19
20 defined( 'ABSPATH' ) || exit;
21
22
23 /**
24 * Builds the dock items array from the admin menu data.
25 *
26 * Iterates through the global $menu and $submenu arrays, filters out
27 * separators and items the current user can't access, and returns a
28 * clean array of dock items ready for JSON serialization.
29 *
30 * @return array[] Array of dock item arrays, each containing:
31 * id, title, icon, url, badge, submenu.
32 */
33 function openstation_build_dock_items() {
34 global $menu, $submenu;
35
36 if ( empty( $menu ) ) {
37 return array();
38 }
39
40 $items = array();
41
42 foreach ( $menu as $item ) {
43 // Skip separators.
44 if ( ! empty( $item[4] ) && false !== strpos( $item[4], 'wp-menu-separator' ) ) {
45 continue;
46 }
47
48 // Skip items without a slug.
49 if ( empty( $item[2] ) ) {
50 continue;
51 }
52
53 // Check capability.
54 if ( ! empty( $item[1] ) && ! current_user_can( $item[1] ) ) {
55 continue;
56 }
57
58 // Skip menus something took out of the classic sidebar. A dock
59 // that shows what wp-admin hides isn't a faithful mirror of the
60 // menu, and on WordPress.com it double-renders every entry
61 // Jetpack replaced with a Calypso link.
62 if ( openstation_menu_item_is_hidden( $item ) ) {
63 continue;
64 }
65
66 $title = openstation_menu_item_title( $item[0] );
67
68 // Extract badge count from the title HTML.
69 $badge = 0;
70 if ( preg_match( '/class="(?:update-plugins|awaiting-mod)[^"]*count-(\d+)"/', $item[0], $matches ) ) {
71 $badge = (int) $matches[1];
72 }
73
74 // The Plugins menu badge in `wp-admin/menu.php` is built from
75 // `count( $update_plugins->response )` — a raw transient count
76 // that can include orphan rows (deleted plugin files, entries
77 // injected by third-party update servers for plugins that
78 // aren't installed locally). Our Plugins window's "Update
79 // available" filter only counts updates whose key intersects
80 // `get_plugins()`, because every row in the window comes from
81 // REST `/wp/v2/plugins` which iterates `get_plugins()`.
82 // Recompute the dock badge from the same intersection so the
83 // dock count always agrees with what the window shows (GH#258).
84 if (
85 'plugins.php' === $item[2] &&
86 ! is_multisite() &&
87 function_exists( 'openstation_plugins_window_count_visible_updates' )
88 ) {
89 $badge = openstation_plugins_window_count_visible_updates();
90 }
91
92 // Determine the icon. Menu entries can set `$item[6]` to anything
93 // — a dashicon class, a remote URL, a data:URI, 'none', or 'div'
94 // — so normalize before we serialize it for the shell JS.
95 //
96 // A blanked value falls back to whatever the row carried before
97 // anything on `admin_menu` rewrote it, which is how plugin
98 // artwork survives Jetpack's SVG-to-stylesheet move on
99 // WordPress.com — see `openstation_snapshot_menu_icons()`.
100 $raw_icon = (string) ( $item[6] ?? '' );
101 if ( '' === $raw_icon || 'none' === $raw_icon || 'div' === $raw_icon ) {
102 $snapshot = openstation_menu_icon_snapshot();
103 if ( isset( $snapshot[ $item[2] ] ) ) {
104 $raw_icon = $snapshot[ $item[2] ];
105 }
106 }
107 $icon = openstation_sanitize_dock_icon( $raw_icon );
108
109 // Build the full URL for the menu item.
110 //
111 // `$parent_url` is the slug-derived URL (`admin.php?page=<slug>`
112 // for plugin pages, the file path for Core ones). It's the
113 // reference value the self-link strip below compares against.
114 // The effective `$url` we ship to the shell can be rewritten
115 // further down to the first visible submenu's URL — see the
116 // note after the loop.
117 $parent_url = openstation_menu_item_url( $item[2] );
118 $parent_external = openstation_menu_item_is_external( $parent_url );
119
120 // A menu owned by a regular plugin is allowed to keep off-site
121 // children — a docs or support link under a plugin's own menu is
122 // a normal thing to ship, and the flyout marks it as leaving the
123 // site. Everything else drops them: a Core menu whose child was
124 // repointed off-site (WordPress.com does this to Appearance →
125 // Themes) gets its wp-admin original back instead, below.
126 $plugin_file = openstation_resolve_menu_plugin_file( $item[2] );
127 $allow_external_subs = null !== $plugin_file && ! $parent_external;
128
129 // Build submenu items.
130 //
131 // WordPress auto-prepends a self-link entry to every parent
132 // menu's `$submenu[$slug]` (the first child shares the parent's
133 // slug + URL — that's what `add_menu_page()` generates so the
134 // admin UI can render a clickable parent in the sidebar). For
135 // the shell's JS surface we strip this entry so:
136 //
137 // - `submenu.length === 0` reliably means "no real children"
138 // (the right-click submenu popover stays suppressed; the
139 // in-window tab strip stays hidden).
140 // - `submenu.length > 0` reliably means "has real child links"
141 // — every entry points at a distinct URL.
142 //
143 // Detection by URL (post-`openstation_menu_item_url()` normalize)
144 // rather than slug equality covers plugins that register a child
145 // at a different slug pointing at the parent's URL.
146 //
147 // Two passes, because the second decision depends on the first:
148 // a `hide-if-js` row is normally noise, but when it is the
149 // wp-admin original of an off-site row we just dropped, it is
150 // the route back to the page Core intended. The original takes
151 // the replacement's place in the list, so the menu reads the way
152 // it would have if nothing had swapped the row out.
153 $rows = array();
154 $restore_slots = array();
155 $dropped_off_site = 0;
156 if ( ! empty( $submenu[ $item[2] ] ) ) {
157 foreach ( $submenu[ $item[2] ] as $sub_item ) {
158 if ( ! empty( $sub_item[1] ) && ! current_user_can( $sub_item[1] ) ) {
159 continue;
160 }
161 // No `hide-if-no-customize` filter here. WordPress tags
162 // Appearance → Customize / Header / Background with that
163 // class; the semantics are "shown by default; hide only
164 // when `<body class=\"no-customize-support\">`". The
165 // Customizer is supported inside chromeless iframes, so
166 // these entries belong in the dock.
167 $sub_url = openstation_menu_item_url( $sub_item[2] );
168 $sub_external = openstation_menu_item_is_external( $sub_url );
169
170 if ( $sub_external && ! $allow_external_subs ) {
171 ++$dropped_off_site;
172 // Leave a slot behind, in case the wp-admin row this
173 // entry displaced is still in the list.
174 $dropped_title = openstation_menu_item_title( $sub_item[0] );
175 if ( '' !== $dropped_title && ! isset( $restore_slots[ $dropped_title ] ) ) {
176 $rows[] = array( 'restore' => $dropped_title );
177 $restore_slots[ $dropped_title ] = count( $rows ) - 1;
178 }
179 continue;
180 }
181
182 $rows[] = array(
183 'raw_title' => $sub_item[0],
184 'slug' => (string) $sub_item[2],
185 'url' => $sub_url,
186 'external' => $sub_external,
187 'hidden' => openstation_menu_item_is_hidden( $sub_item ),
188 );
189 }
190 }
191
192 // Second pass. A hidden row moves into the slot its replacement
193 // left; one whose replacement was the top-level slug itself
194 // stays where it is (there is no slot — the menu row is not part
195 // of this list). Every other hidden row, and every slot nothing
196 // claimed, drops out.
197 $restored = array();
198 $keep = array_fill( 0, count( $rows ), true );
199 foreach ( $rows as $i => $row ) {
200 if ( isset( $row['restore'] ) || ! $row['hidden'] ) {
201 continue;
202 }
203 $keep[ $i ] = false;
204 $row_title = openstation_menu_item_title( $row['raw_title'] );
205 if ( '' === $row_title || isset( $restored[ $row_title ] ) ) {
206 continue;
207 }
208 if ( isset( $restore_slots[ $row_title ] ) ) {
209 $rows[ $restore_slots[ $row_title ] ] = $row;
210 $restored[ $row_title ] = true;
211 } elseif ( $parent_external && $row_title === $title ) {
212 // The menu's own row, hidden in place. WordPress builds
213 // a parent's self-link by copying the menu row's first
214 // four fields, so its label is the menu's label, which
215 // is what makes the comparison hold.
216 $keep[ $i ] = true;
217 $restored[ $row_title ] = true;
218 }
219 }
220
221 // Last resort for a menu whose own slug points off-site: if
222 // nothing on-site survived, take the first hidden on-site row
223 // rather than lose the menu. The label comparison above is the
224 // precise answer and covers the ordinary case, but it breaks the
225 // moment a host relabels the menu row without relabelling the
226 // self-link it already generated. Showing a row someone hid
227 // beats dropping a working menu off the dock.
228 if ( $parent_external ) {
229 $has_on_site = false;
230 foreach ( $rows as $i => $row ) {
231 if ( ! isset( $row['restore'] ) && $keep[ $i ] && ! $row['external'] ) {
232 $has_on_site = true;
233 break;
234 }
235 }
236 if ( ! $has_on_site ) {
237 foreach ( $rows as $i => $row ) {
238 if ( isset( $row['restore'] ) || ! $row['hidden'] || $row['external'] ) {
239 continue;
240 }
241 $keep[ $i ] = true;
242 break;
243 }
244 }
245 }
246
247 $kept_rows = array();
248 foreach ( $rows as $i => $row ) {
249 if ( isset( $row['restore'] ) || ! $keep[ $i ] ) {
250 continue;
251 }
252 $kept_rows[] = $row;
253 }
254 $rows = $kept_rows;
255
256 // When the top-level slug itself points off-site, the menu's
257 // identity is now whichever child survived — adopt it before the
258 // self-link strip runs, so a restored original collapses into
259 // `selfLabel` instead of becoming a child that duplicates its
260 // own parent.
261 //
262 // Identity travels with it. Everything below keys off the menu's
263 // slug — whether it's a Core menu, whether a plugin owns it,
264 // whether it opens more than one window, and which slug the
265 // `openstation_dock_item` filter is told about. Left on the
266 // off-site slug, a rescued Plugins tile reads as a plugin menu
267 // owned by whoever registered the replacement, sorts to the far
268 // end of the dock, and offers to deactivate them.
269 $identity_slug = (string) $item[2];
270 if ( $parent_external ) {
271 foreach ( $rows as $row ) {
272 if ( ! $row['external'] ) {
273 $parent_url = $row['url'];
274 $identity_slug = $row['slug'];
275 break;
276 }
277 }
278 }
279
280 // A menu that only ever pointed at its children, and whose
281 // children we just took away. Checked only for menus the
282 // off-site rule actually touched, so a menu registering its page
283 // hook in some way we don't recognise is left exactly as it was.
284 $parent_is_container = $dropped_off_site > 0
285 && ! $parent_external
286 && ! openstation_menu_slug_has_page( $item[2] );
287
288 $url = $parent_url;
289 $sub_items = array();
290 $first_visible_sub_url = null;
291 $has_self_link = false;
292 $self_label = '';
293 foreach ( $rows as $row ) {
294 $sub_url = $row['url'];
295 if ( $parent_is_container && $sub_url === $parent_url ) {
296 // A row pointing back at a menu with no page is a dead
297 // end, not a way back — it can't name the menu and it
298 // can't stand in for it.
299 continue;
300 }
301 // Capture the first capability-passing submenu URL so
302 // we can use it as the parent's effective URL below
303 // (mirrors `wp-admin/menu-header.php`). Captured BEFORE
304 // the self-link strip so plugins whose first submenu IS
305 // the auto-prepended self-link land on the parent URL
306 // (a no-op rewrite — preserves existing behavior). Never
307 // an off-site child, which would take the whole tile with
308 // it when the final external check runs.
309 if ( null === $first_visible_sub_url && ! $row['external'] ) {
310 $first_visible_sub_url = $sub_url;
311 }
312 // Self-link strip — `$sub_url === $parent_url` covers
313 // WP's auto-prepended entry AND any plugin-registered
314 // alias that happens to land on the parent URL.
315 if ( $sub_url === $parent_url ) {
316 $has_self_link = true;
317 // Keep its LABEL, though. The stripped entry is a
318 // real row in wp-admin's own menu ("All Posts",
319 // "All Pages"), and the constellation flyout lists
320 // it as the first thing the menu opens — a list of
321 // a menu's pages that omits its main page reads as
322 // a bug.
323 //
324 // Carried separately rather than left in `submenu`
325 // because `submenu` has two other consumers that
326 // need it to mean "distinct child links only": the
327 // in-window tab strip, which would grow a duplicate
328 // first tab, and the right-click popover, which is
329 // suppressed on `length === 0`.
330 //
331 // First one only — a plugin can register several
332 // aliases onto the parent URL, and the canonical
333 // self-link is the one WordPress prepends.
334 if ( '' === $self_label ) {
335 $self_label = openstation_menu_item_title( $row['raw_title'] );
336 }
337 continue;
338 }
339 // Skip entries with no resolvable title. Plugins (e.g.
340 // WooCommerce's `wc-addons` Extensions row) register
341 // `menu_title => null` to hide a row from classic admin's
342 // left menu while keeping the page reachable. Without
343 // this guard the dock renders an empty, label-less tab
344 // that visually duplicates a sibling entry.
345 $sub_title = openstation_menu_item_title( $row['raw_title'] );
346 if ( '' === $sub_title ) {
347 continue;
348 }
349 $sub_entry = array(
350 'title' => $sub_title,
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'],
355 );
356 if ( $row['external'] ) {
357 // Consumers that route a URL into a window skip these;
358 // the ones that can hand a link to the browser mark
359 // them as leaving the site.
360 //
361 // `offSite` rather than `external`: the window's tab
362 // strip already calls plugin-opened sub-iframe tabs
363 // "external" (`data-kind="external"`), and that is a
364 // different thing entirely.
365 $sub_entry['offSite'] = true;
366 }
367 $sub_items[] = $sub_entry;
368 }
369
370 // Mirror `wp-admin/menu-header.php`: when a parent menu has any
371 // visible submenu, classic admin rewrites the parent's
372 // clickable URL to the first submenu's URL. Plugins like
373 // WooCommerce rely on this — their top-level slug
374 // (`woocommerce`) has no working callback and 500s when hit
375 // directly. The real landing page is the first submenu
376 // (`?page=wc-admin` for WC). Without this rewrite the dock
377 // icon points users at a broken URL that classic admin would
378 // never have linked to.
379 //
380 // A menu that registered a self-link has a working page of its
381 // own and keeps it, wherever in the list that link sits. Only
382 // the WooCommerce shape — no self-link at all — needs a child to
383 // stand in. Position matters here because a restored wp-admin
384 // row inherits the slot its off-site replacement held, which on
385 // WordPress.com puts `plugin-install.php` first under Plugins.
386 if ( null !== $first_visible_sub_url && ! $has_self_link ) {
387 $url = $first_visible_sub_url;
388 }
389
390 // Nothing on this menu resolves to a page we can open. Hosts
391 // that link their own control panel from the admin menu
392 // (WordPress.com's My Home, Theme Showcase, Hosting) land here,
393 // and so does a Core menu whose slug was repointed off-site with
394 // no wp-admin child left to fall back to.
395 if ( openstation_menu_item_is_external( $url ) ) {
396 continue;
397 }
398
399 // A container menu with nothing left to stand in for it. Its
400 // URL resolves to core's "Cannot load <slug>." page, which is a
401 // worse tile than no tile.
402 if ( $parent_is_container && $url === $parent_url ) {
403 continue;
404 }
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
445 $dock_item = array(
446 'id' => sanitize_key( $item[5] ?? $item[2] ),
447 'title' => $title,
448 'icon' => $icon,
449 'url' => $url,
450 'badge' => $badge,
451 'submenu' => $sub_items,
452 // Label of the stripped self-link ("All Posts"), for
453 // surfaces that list a menu's pages and want its main page
454 // named the way wp-admin names it. Empty when the menu had
455 // no self-link to strip.
456 'selfLabel' => $self_label,
457 'multi' => openstation_dock_item_is_multi( $identity_slug ),
458 'placement' => openstation_dock_placement( $identity_slug ),
459 'isCore' => openstation_is_core_menu_slug( $identity_slug ),
460 'pluginFile' => $identity_slug === (string) $item[2]
461 ? $plugin_file
462 : openstation_resolve_menu_plugin_file( $identity_slug ),
463 'pluginName' => null,
464 );
465 if ( $dock_item['pluginFile'] ) {
466 $dock_item['pluginName'] = openstation_plugin_display_name( $dock_item['pluginFile'] );
467 }
468
469 /**
470 * Filters a single dock item's data.
471 *
472 * @param array $dock_item The dock item data.
473 * @param string $menu_slug The menu slug.
474 */
475 $dock_item = apply_filters( 'openstation_dock_item', $dock_item, $identity_slug );
476
477 $items[] = $dock_item;
478 }
479
480 /**
481 * Filters the dock items before they are passed to JavaScript.
482 *
483 * @param array[] $items Array of dock item arrays.
484 */
485 return apply_filters( 'openstation_dock_items', $items );
486 }
487
488 /**
489 * Whether a resolved menu URL points at a host other than this site's.
490 *
491 * OpenStation opens admin pages inside iframes, and an off-site URL
492 * cannot load in one — the remote origin's `X-Frame-Options` /
493 * `frame-ancestors` header refuses it. Hosts that extend the admin
494 * menu with links to their own control panel (WordPress.com registers
495 * My Home, Theme Showcase, Hosting and friends as `wordpress.com`
496 * URLs) would therefore fill the dock with tiles that can only ever
497 * escape to a browser tab, which breaks the shell's navigation model.
498 * Those entries are dropped from the payload instead.
499 *
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.
504 *
505 * @param string $url Absolute URL, as returned by `openstation_menu_item_url()`.
506 * @return bool True when the URL is off-site.
507 */
508 function openstation_menu_item_is_external( $url ) {
509 $host = wp_parse_url( (string) $url, PHP_URL_HOST );
510 $external = false;
511
512 if ( $host ) {
513 $ours = array();
514 foreach ( array( openstation_menu_admin_url(), admin_url(), home_url() ) as $known ) {
515 $known_host = wp_parse_url( $known, PHP_URL_HOST );
516 if ( $known_host ) {
517 $ours[] = strtolower( $known_host );
518 }
519 }
520 $external = ! in_array( strtolower( $host ), $ours, true );
521 }
522
523 /**
524 * Filters whether an admin-menu URL counts as off-site.
525 *
526 * @param bool $external Whether the URL points off-site.
527 * @param string $url The resolved menu URL.
528 */
529 return (bool) apply_filters( 'openstation_menu_item_is_external', $external, $url );
530 }
531
532 /**
533 * Whether a `$menu` / `$submenu` row carries the `hide-if-js` class.
534 *
535 * Core never sets it on a menu row, so it reads as "some other code
536 * took this entry out of the sidebar". Jetpack's admin-menu
537 * customisation on WordPress.com uses it heavily: rather than replace
538 * a Core entry with its wordpress.com counterpart, it marks the
539 * original `hide-if-js` and appends a duplicate pointing at Calypso.
540 * Honouring the class is what keeps those pairs from rendering twice
541 * in the dock.
542 *
543 * @param array $item A `$menu` or `$submenu` row.
544 * @return bool True when the row is hidden from the classic sidebar.
545 */
546 function openstation_menu_item_is_hidden( $item ) {
547 return ! empty( $item[4] ) && false !== strpos( (string) $item[4], 'hide-if-js' );
548 }
549
550 /**
551 * Whether a top-level menu slug has a page of its own behind it.
552 *
553 * `add_menu_page()` accepts a `null` callback, which registers a menu
554 * that is nothing but a container for its children — WordPress links
555 * such a parent to its first submenu and `admin.php` refuses the slug
556 * directly with "Cannot load <slug>." WordPress.com's Upgrades menu is
557 * one: `paid-upgrades.php` has no callback and no self-link, and every
558 * child is a wordpress.com URL. Drop the children and the tile is left
559 * pointing at core's error page.
560 *
561 * Two ways a slug earns a page: it names a real file under `wp-admin/`,
562 * or something is listening on its page hook — the same `has_action()`
563 * test `get_plugin_page_hook()` makes before `admin.php` gives up.
564 * Anything we can't answer counts as a page, so an unusual registration
565 * costs a menu nothing.
566 *
567 * @param string $slug The menu slug from `$menu[$i][2]`.
568 * @return bool False only when the slug is provably a container.
569 */
570 function openstation_menu_slug_has_page( $slug ) {
571 if ( openstation_is_admin_file_slug( $slug ) ) {
572 return true;
573 }
574
575 if ( ! function_exists( 'get_plugin_page_hookname' ) ) {
576 return true;
577 }
578
579 $hookname = get_plugin_page_hookname( $slug, '' );
580 if ( empty( $hookname ) ) {
581 return true;
582 }
583
584 return has_action( $hookname );
585 }
586
587 /**
588 * Lazy accessor for the pre-rewrite menu icon snapshot: `slug → icon`.
589 *
590 * Populated by {@see openstation_snapshot_menu_icons()}.
591 *
592 * @return array<string,string>
593 */
594 function &openstation_menu_icon_snapshot() {
595 static $map = null;
596 if ( null === $map ) {
597 $map = array();
598 }
599 return $map;
600 }
601
602 /**
603 * Record the first real icon each menu row is seen wearing.
604 *
605 * A menu row's icon is not final when it is registered. Anything on
606 * `admin_menu` can rewrite `$menu[ $i ][6]`, and the rewrite that hurts
607 * is to `'none'` — the row keeps its picture in the sidebar, painted
608 * from a stylesheet instead, and the menu array stops carrying it. The
609 * dock reads the array, so those menus arrived wearing a generic gear.
610 * Jetpack's `override_svg_icons()` does this to every SVG-data-URI icon
611 * on WordPress.com, which is where it was found, but nothing about the
612 * move is specific to that host.
613 *
614 * Rather than sit at one priority chosen to undercut one known rewriter,
615 * sample repeatedly and **never overwrite**: the map keeps the earliest
616 * real icon each slug had, whenever it appeared and whoever blanked it
617 * afterwards. Write-once is safe because the map is only ever consulted
618 * as a fallback — a menu that genuinely changes its icon still ships the
619 * live value.
620 *
621 * A slug that had no real icon at any sample point is simply absent, and
622 * the caller lands on the generic fallback it would have had anyway.
623 */
624 function openstation_snapshot_menu_icons() {
625 global $menu;
626
627 if ( ! is_array( $menu ) ) {
628 return;
629 }
630
631 $map = &openstation_menu_icon_snapshot();
632
633 foreach ( $menu as $item ) {
634 if ( empty( $item[2] ) || empty( $item[6] ) ) {
635 continue;
636 }
637 $slug = (string) $item[2];
638 if ( isset( $map[ $slug ] ) ) {
639 continue;
640 }
641 $icon = (string) $item[6];
642 if ( 'none' === $icon || 'div' === $icon ) {
643 continue;
644 }
645 $map[ $slug ] = $icon;
646 }
647 }
648 // Spread across the hook rather than parked just below any one
649 // rewriter: registrations and rewrites both happen at arbitrary
650 // priorities, and only a sample taken before a given rewrite can see
651 // what it overwrote.
652 foreach ( array( 11, 100, 1000, 99998, PHP_INT_MAX ) as $openstation_icon_snapshot_priority ) {
653 add_action( 'admin_menu', 'openstation_snapshot_menu_icons', $openstation_icon_snapshot_priority );
654 }
655 unset( $openstation_icon_snapshot_priority );
656
657 /**
658 * Sanitizes a dock icon value for safe injection into the shell JS.
659 *
660 * Menu items can set their icon to one of:
661 *
662 * - A Dashicons class (e.g. `dashicons-admin-post`)
663 * - An http/https URL pointing at an image asset
664 * - A `data:image/svg+xml;base64,…` URI (common for plugins that
665 * ship inline vector art — Jetpack, WooCommerce, etc.). Rendered
666 * as a CSS background-image, where per-spec SVG script content
667 * does not execute, so the surface is safe.
668 * - `'none'` or `'div'` (CSS hooks, no icon asset). The dock's JS
669 * layer extracts the real icon from the hidden `#adminmenu` DOM
670 * for these cases.
671 *
672 * Inline SVG data URIs (`data:image/svg+xml;base64,…` and
673 * `data:image/svg+xml,…`) are also accepted because that's how the
674 * vast majority of WP plugins ship their menu icon — Yoast,
675 * WooCommerce, Jetpack, Elementor, et al. all register `$menu[$i][6]`
676 * as an SVG data URI. Other `data:` schemes (`data:text/html`,
677 * `data:application/javascript`, …) and raw `javascript:` / `vbscript:`
678 * / `file:` schemes remain rejected. The shell renders the SVG via a
679 * CSS `background-image`, which (per the modern browser security model
680 * shared with `<img>`) sandboxes scripts inside the SVG so they do not
681 * execute.
682 *
683 * The return value is always a string safe to drop into an `img.src`,
684 * a CSS class, or a CSS `url()` background without further escaping.
685 *
686 * @param mixed $icon Raw icon value from the menu registration.
687 * @return string Sanitized icon string.
688 */
689 function openstation_sanitize_dock_icon( $icon ) {
690 $fallback = 'dashicons-admin-generic';
691 if ( ! is_string( $icon ) || '' === $icon ) {
692 return $fallback;
693 }
694
695 $icon = trim( $icon );
696
697 if ( 'none' === $icon || 'div' === $icon ) {
698 return $fallback;
699 }
700
701 if ( 0 === strpos( $icon, 'dashicons-' ) ) {
702 // Allow only the safe subset of characters a Dashicons class can
703 // contain — prevents class-attribute break-out via spaces or
704 // quotes if a plugin registers a malicious "dashicons-…" value.
705 return preg_replace( '/[^a-z0-9_-]/', '', $icon );
706 }
707
708 // http/https URL — the icon is a hosted image.
709 if ( 0 === stripos( $icon, 'http://' ) || 0 === stripos( $icon, 'https://' ) ) {
710 $clean = esc_url_raw( $icon, array( 'http', 'https' ) );
711 return $clean ? $clean : $fallback;
712 }
713
714 // `data:image/svg+xml` — the canonical inline-icon shape WordPress
715 // plugins use for their admin-menu icon (`$menu[$i][6]`). Two valid
716 // payload encodings: base64 (`;base64,<base64>`) and URL-encoded
717 // (`,<percent-encoded>`). Reject everything outside the SVG MIME so
718 // `data:text/html` and `data:application/javascript` still bounce.
719 //
720 // Strict whole-string regex — no embedded whitespace, no smuggled
721 // quotes, no second `data:` prefix. Case-insensitive on the scheme
722 // alone since `Data:` and `DATA:` are syntactically valid but the
723 // payload portion stays case-sensitive (base64 alphabet is).
724 if ( 0 === stripos( $icon, 'data:image/svg+xml' ) ) {
725 if (
726 preg_match( '#^data:image/svg\+xml;base64,[A-Za-z0-9+/=]+$#i', $icon )
727 || preg_match( '#^data:image/svg\+xml,[A-Za-z0-9._~!$&\'()*+,;=:@/?%-]+$#i', $icon )
728 ) {
729 return $icon;
730 }
731 // Malformed SVG data URI — fall through to fallback rather than
732 // pass a half-validated string through to the renderer.
733 }
734
735 return $fallback;
736 }
737
738 /**
739 * Decides whether a given admin page should support multiple open windows.
740 *
741 * List-style screens (Posts, Pages, custom post types, Media, Users,
742 * Comments, taxonomy terms) often benefit from being open more than once:
743 * a writer may want to read one post while drafting another, compare two
744 * users side-by-side, pick media from one window and drop it into a draft
745 * in another. Singleton-ish screens (Dashboard, Settings, Tools, Profile)
746 * have a single logical state — opening two makes no sense.
747 *
748 * The default rule matches the base filename of the menu slug against a
749 * known list. Plugin authors can override via the
750 * `openstation_dock_item_multi` filter to mark any custom page as multi
751 * (or force a stock list page into singleton mode).
752 *
753 * @param string $menu_slug The raw menu slug (e.g. `edit.php`, `upload.php`,
754 * or `my-plugin-page`). Query strings are preserved
755 * so `edit.php?post_type=page` resolves correctly.
756 * @return bool True if this page supports multiple simultaneous windows.
757 */
758 function openstation_dock_item_is_multi( $menu_slug ) {
759 // Multi-capable admin files. Match by the base file regardless of
760 // any query string (post_type, taxonomy, page, paged, etc.) so every
761 // CPT and every taxonomy inherits the same rule as their parent.
762 $multi_files = array(
763 'edit.php',
764 'edit-tags.php',
765 'upload.php',
766 'users.php',
767 'edit-comments.php',
768 );
769
770 $base = strtok( (string) $menu_slug, '?' );
771 $multi = in_array( $base, $multi_files, true );
772
773 /**
774 * Filters whether a dock item supports multiple open windows.
775 *
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.
781 *
782 * @param bool $multi Whether this page is multi-capable.
783 * @param string $menu_slug The menu slug (e.g. `edit.php?post_type=page`).
784 */
785 return (bool) apply_filters( 'openstation_dock_item_multi', $multi, $menu_slug );
786 }
787
788 /**
789 * Returns true when `$menu_slug` maps to a first-party WordPress
790 * Core admin menu item (Dashboard, Posts, Pages, Media, Settings,
791 * etc.), false otherwise. The caller uses the answer as an ordering
792 * hint — core items are placed ahead of plugin items in the
793 * unified dock rail.
794 *
795 * The rule:
796 *
797 * 1. Any known core admin filename (index.php, edit.php, upload.php,
798 * themes.php, plugins.php, users.php, tools.php, options-*.php,
799 * edit-comments.php, etc.) is Core.
800 * 2. Any Custom Post Type route (`edit.php?post_type=…`) is Core —
801 * CPTs are content-oriented even when a plugin registers them,
802 * so they belong next to Posts / Pages in the dock.
803 * 3. Every `admin.php?page=*` route is Plugin — that's WP's
804 * universal "a plugin registered its own top-level admin route"
805 * signal.
806 * 4. Anything else is treated as Plugin (safer default — plugins
807 * with custom top-level files can still opt in via the filter
808 * below).
809 *
810 * Plugins + site admins can override any answer via
811 * `openstation_dock_placement`:
812 *
813 * ```php
814 * // Keep Jetpack on the left dock:
815 * add_filter( 'openstation_dock_placement', function ( $placement, $slug ) {
816 * return 'jetpack' === $slug ? 'dock' : $placement;
817 * }, 10, 2 );
818 * ```
819 *
820 * @param string $menu_slug Menu item slug (e.g. `edit.php`, `edit.php?post_type=foo`, `woocommerce`).
821 * @return bool True when the slug is a core admin page.
822 */
823 function openstation_is_core_menu_slug( $menu_slug ) {
824 $slug = (string) $menu_slug;
825 $base = strtok( $slug, '?' );
826
827 // Known top-level core admin files. Stable across WP versions —
828 // additions happen maybe once a release, removals almost never.
829 $core_files = array(
830 'index.php', // Dashboard
831 'edit.php', // Posts (+ CPTs via ?post_type=)
832 'edit-comments.php', // Comments
833 'upload.php', // Media
834 'edit-tags.php', // Taxonomies
835 'term.php', // Single-term edit
836 'post-new.php', // New post form
837 'post.php', // Edit-post form
838 'themes.php', // Appearance
839 'nav-menus.php', // Menus (Appearance > Menus)
840 'widgets.php', // Widgets (Appearance > Widgets)
841 'customize.php', // Customizer
842 'plugins.php', // Plugins
843 'plugin-install.php', // Plugins > Add New
844 'plugin-editor.php', // Plugins > Editor
845 'users.php', // Users
846 'user-new.php', // Users > Add New
847 'profile.php', // Profile
848 'user-edit.php', // Edit another user
849 'tools.php', // Tools
850 'import.php', // Tools > Import
851 'export.php', // Tools > Export
852 'site-health.php', // Tools > Site Health
853 'export-personal-data.php',
854 'erase-personal-data.php',
855 'options-general.php', // Settings
856 'options-writing.php', // Settings > Writing
857 'options-reading.php', // Settings > Reading
858 'options-discussion.php', // Settings > Discussion
859 'options-media.php', // Settings > Media
860 'options-permalink.php', // Settings > Permalinks
861 'options-privacy.php', // Settings > Privacy
862 'link-manager.php', // Link manager (legacy)
863 'update-core.php', // Dashboard > Updates
864 );
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
877 return in_array( $base, $core_files, true );
878 }
879
880 /**
881 * Resolve the plugin file (e.g. `woocommerce/woocommerce.php`) that owns
882 * a given top-level admin menu slug, by reflecting on the callbacks
883 * registered for the menu's page hook.
884 *
885 * Returns the plugin's main file path (relative to `WP_PLUGIN_DIR`) when
886 * the menu was registered by a regular plugin, `null` otherwise. Core
887 * menus, mu-plugins, drop-ins, theme-registered menus, and OpenStation
888 * itself all return `null` — none of these are deactivatable through the
889 * `wp/v2/plugins` REST route, so the dock right-click menu should not
890 * offer a deactivate action for them.
891 *
892 * Resolution algorithm:
893 *
894 * 1. Skip core menu slugs outright — `plugins.php`, `edit.php?post_type=…`,
895 * etc. are never owned by a deactivatable plugin.
896 * 2. Compute the page hookname via `get_plugin_page_hookname()` and read
897 * `$wp_filter[ $hookname ]->callbacks`. This is the action list WP
898 * walks to render the menu's body — the plugin's own render callback
899 * lives here.
900 * 3. Reflect each callback to find its declaring file. Match the file
901 * path against `WP_PLUGIN_DIR/<folder>/…` and use `<folder>` to look
902 * up an entry in `get_plugins()`. Return the matching `<folder>/<file>.php`.
903 * 4. Exclude OpenStation itself — deactivating from inside the shell
904 * is handled by the plugins-window's self-deactivate path.
905 *
906 * @param string $menu_slug The menu slug from `$menu[$i][2]` (e.g. `woocommerce`,
907 * `admin.php?page=jetpack`, `edit.php?post_type=foo`).
908 * @return string|null Plugin file path relative to `WP_PLUGIN_DIR`, or null
909 * when the slug isn't owned by a deactivatable plugin.
910 */
911 function openstation_resolve_menu_plugin_file( $menu_slug ) {
912 $slug = (string) $menu_slug;
913
914 // `get_plugin_page_hookname` + `get_plugins` come from
915 // `wp-admin/includes/plugin.php`, which Core loads itself on
916 // every admin request. The resolver only runs in admin context
917 // (called during `admin_enqueue_scripts` and the `_admin_menu`
918 // tracker), so the symbols are always available. Bail rather
919 // than `require_once` something that's Core's job to load.
920 if ( ! function_exists( 'get_plugin_page_hookname' ) || ! function_exists( 'get_plugins' ) ) {
921 return null;
922 }
923
924 $self_basename = defined( 'OPENSTATION_FILE' ) ? plugin_basename( OPENSTATION_FILE ) : '';
925
926 // Strategy 1 — registration-time attribution. The admin_menu hook
927 // wrapper (see `openstation_install_menu_attribution_tracker`) snapshots
928 // `$menu`/`$submenu` around every admin_menu callback and records
929 // "this plugin file added this slug". This is the authoritative
930 // source — it captures menus whose page hook isn't predictable from
931 // the slug (e.g. WC's `wc-admin&path=/marketing`) and handles
932 // callbacks that simply forward to a shared renderer (which
933 // reflection would mis-attribute).
934 $map = openstation_menu_attribution_map();
935 if ( isset( $map[ $slug ] ) ) {
936 $plugin_file = $map[ $slug ];
937 if ( $self_basename && $plugin_file === $self_basename ) {
938 return null;
939 }
940 return $plugin_file;
941 }
942
943 // Strategy 2 — CPT / taxonomy registration tracker. Core's `edit.php`
944 // / `edit-tags.php` handle the render, so the page hook would never
945 // point at the registering plugin. We caught the plugin at
946 // `register_post_type()` / `register_taxonomy()` time via
947 // `debug_backtrace()`.
948 $tracked = openstation_lookup_taxonomy_or_post_type_plugin_file( $slug );
949 if ( null !== $tracked ) {
950 if ( $self_basename && $tracked === $self_basename ) {
951 return null;
952 }
953 return $tracked;
954 }
955
956 $base = strtok( $slug, '?' );
957
958 // Cheap reject: literal core PHP files with no `?page=` parameter
959 // (the universal "a plugin registered an admin route" signal). We
960 // can't reuse `openstation_is_core_menu_slug()` here — that
961 // classifier strtok's the query string and treats `admin.php?page=foo`
962 // as core, which would hide every plugin-registered top-level tile.
963 if ( openstation_is_pure_core_file( $base ) && false === strpos( $slug, '?page=' ) ) {
964 return null;
965 }
966
967 // Strategy 3 — page-hook reflection fallback. The earlier strategies
968 // can miss when a plugin is loaded after admin_menu has fired (rare),
969 // or when the menu was injected by a non-admin_menu pathway. Reflect
970 // on `$wp_filter[$hookname]` to find the callback's declaring file
971 // and map it back to an active plugin.
972 global $wp_filter;
973 $hookname = get_plugin_page_hookname( $slug, '' );
974 if ( empty( $hookname ) || empty( $wp_filter[ $hookname ] ) ) {
975 return null;
976 }
977
978 $hook = $wp_filter[ $hookname ];
979 foreach ( $hook->callbacks as $cbs ) {
980 foreach ( $cbs as $cb ) {
981 $plugin_file = openstation_plugin_file_for_callback( $cb['function'] ?? null );
982 if ( ! $plugin_file ) {
983 continue;
984 }
985 if ( $self_basename && $plugin_file === $self_basename ) {
986 return null;
987 }
988 return $plugin_file;
989 }
990 }
991
992 return null;
993 }
994
995 /**
996 * Look up the human-readable display name for a plugin file. Returns
997 * the plugin folder name as a last-resort fallback if `get_plugins()`
998 * has no entry (extremely rare — would mean the plugin file isn't
999 * installed but somehow registered a menu).
1000 *
1001 * @param string $plugin_file Plugin file relative to `WP_PLUGIN_DIR`.
1002 * @return string Display name.
1003 */
1004 function openstation_plugin_display_name( $plugin_file ) {
1005 if ( ! function_exists( 'get_plugins' ) ) {
1006 $dir = strtok( $plugin_file, '/' );
1007 return $dir ? $dir : $plugin_file;
1008 }
1009 $installed = get_plugins();
1010 if ( isset( $installed[ $plugin_file ]['Name'] ) && '' !== $installed[ $plugin_file ]['Name'] ) {
1011 return (string) $installed[ $plugin_file ]['Name'];
1012 }
1013 $folder = strtok( $plugin_file, '/' );
1014 return $folder ? $folder : $plugin_file;
1015 }
1016
1017 /**
1018 * Map an arbitrary filesystem path inside `WP_PLUGIN_DIR` to the
1019 * corresponding plugin file in `get_plugins()`. Returns null when the
1020 * path isn't under the plugins directory, or doesn't match any active
1021 * plugin folder.
1022 *
1023 * @param string $file Absolute filesystem path.
1024 * @return string|null Plugin file (`<folder>/<file>.php`) or null.
1025 */
1026 function openstation_plugin_file_for_path( $file ) {
1027 if ( ! is_string( $file ) || '' === $file ) {
1028 return null;
1029 }
1030 $plugins_dir = wp_normalize_path( WP_PLUGIN_DIR );
1031 $norm = openstation_plugin_link_path( wp_normalize_path( $file ) );
1032 if ( 0 !== strpos( $norm, $plugins_dir . '/' ) ) {
1033 return null;
1034 }
1035 if ( ! function_exists( 'get_plugins' ) ) {
1036 return null;
1037 }
1038 $installed = get_plugins();
1039
1040 $rel = ltrim( substr( $norm, strlen( $plugins_dir ) ), '/' );
1041 $folder = ( false !== strpos( $rel, '/' ) ) ? strtok( $rel, '/' ) : '';
1042
1043 foreach ( $installed as $plugin_file => $_data ) {
1044 if ( '' !== $folder && 0 === strpos( $plugin_file, $folder . '/' ) ) {
1045 return $plugin_file;
1046 }
1047 if ( '' === $folder && $plugin_file === $rel ) {
1048 return $plugin_file;
1049 }
1050 }
1051 return null;
1052 }
1053
1054 /**
1055 * Convenience wrapper: reflect on a callback to find its declaring
1056 * file, then map that file to an active plugin via
1057 * {@see openstation_plugin_file_for_path()}.
1058 *
1059 * @param mixed $callback A WP-style callback.
1060 * @return string|null Plugin file or null.
1061 */
1062 function openstation_plugin_file_for_callback( $callback ) {
1063 $file = openstation_callback_source_file( $callback );
1064 return $file ? openstation_plugin_file_for_path( $file ) : null;
1065 }
1066
1067 /**
1068 * Lazy accessor + lazy initializer for the registration-time menu
1069 * attribution map: `slug → plugin_file`. The map is populated by the
1070 * wrapped admin_menu callbacks installed by
1071 * {@see openstation_install_menu_attribution_tracker()}.
1072 *
1073 * @return array<string,string>
1074 */
1075 function &openstation_menu_attribution_map() {
1076 static $map = null;
1077 if ( null === $map ) {
1078 $map = array();
1079 }
1080 return $map;
1081 }
1082
1083 /**
1084 * Install admin_menu callback wrappers that record which plugin file
1085 * registered each `$menu` / `$submenu` slug.
1086 *
1087 * Approach:
1088 *
1089 * 1. Hooked on `_admin_menu` priority `-PHP_INT_MAX`, just before
1090 * `admin_menu` fires.
1091 * 2. Walk `$wp_filter['admin_menu']->callbacks`. For each callback,
1092 * reflect on the function to find its declaring file → plugin
1093 * file. If the callback doesn't live in `WP_PLUGIN_DIR`, leave it
1094 * alone (Core's own callbacks).
1095 * 3. Replace the callback in-place with a closure that snapshots
1096 * `$menu` and `$submenu` keys, invokes the original, then diffs
1097 * the globals. Every new top-level slug and every new submenu
1098 * entry gets attributed to that plugin file.
1099 *
1100 * This is the source of truth for plugin → menu ownership because it
1101 * captures menus regardless of slug shape, hook name predictability,
1102 * or whether the plugin shares a render callback. Reflection on the
1103 * page hook (in `openstation_resolve_menu_plugin_file`) is now a
1104 * fallback for the rare cases where the tracker wasn't able to install
1105 * in time.
1106 *
1107 * Idempotent — runs at most once per request via a static `$installed`
1108 * flag.
1109 *
1110 * @return void
1111 */
1112 function openstation_install_menu_attribution_tracker() {
1113 static $installed = false;
1114 if ( $installed ) {
1115 return;
1116 }
1117 $installed = true;
1118
1119 global $wp_filter;
1120 if ( empty( $wp_filter['admin_menu'] ) ) {
1121 return;
1122 }
1123 $hook = $wp_filter['admin_menu'];
1124
1125 foreach ( $hook->callbacks as $priority => $cbs ) {
1126 foreach ( $cbs as $id => $cb ) {
1127 $orig = $cb['function'] ?? null;
1128 $plugin_file = openstation_plugin_file_for_callback( $orig );
1129 if ( ! $plugin_file || ! is_callable( $orig ) ) {
1130 continue;
1131 }
1132 $accepted_args = (int) ( $cb['accepted_args'] ?? 1 );
1133
1134 $wrapper = static function () use ( $orig, $plugin_file ) {
1135 global $menu, $submenu;
1136
1137 $before_top_slugs = array();
1138 if ( is_array( $menu ) ) {
1139 foreach ( $menu as $entry ) {
1140 if ( isset( $entry[2] ) ) {
1141 $before_top_slugs[ (string) $entry[2] ] = true;
1142 }
1143 }
1144 }
1145 $before_submenu_keys = is_array( $submenu ) ? array_keys( $submenu ) : array();
1146 $before_submenu_sigs = array();
1147 if ( is_array( $submenu ) ) {
1148 foreach ( $submenu as $parent => $children ) {
1149 $sigs = array();
1150 foreach ( (array) $children as $child ) {
1151 if ( isset( $child[2] ) ) {
1152 $sigs[ (string) $child[2] ] = true;
1153 }
1154 }
1155 $before_submenu_sigs[ $parent ] = $sigs;
1156 }
1157 }
1158
1159 $args = func_get_args();
1160 $return = call_user_func_array( $orig, $args );
1161
1162 $map = &openstation_menu_attribution_map();
1163
1164 if ( is_array( $menu ) ) {
1165 foreach ( $menu as $entry ) {
1166 if ( ! isset( $entry[2] ) ) {
1167 continue;
1168 }
1169 $slug = (string) $entry[2];
1170 if ( ! isset( $before_top_slugs[ $slug ] ) && ! isset( $map[ $slug ] ) ) {
1171 $map[ $slug ] = $plugin_file;
1172 }
1173 }
1174 }
1175
1176 if ( is_array( $submenu ) ) {
1177 foreach ( $submenu as $parent => $children ) {
1178 $prev_sigs = $before_submenu_sigs[ $parent ] ?? array();
1179 foreach ( (array) $children as $child ) {
1180 if ( ! isset( $child[2] ) ) {
1181 continue;
1182 }
1183 $slug = (string) $child[2];
1184 if ( isset( $prev_sigs[ $slug ] ) ) {
1185 continue;
1186 }
1187 if ( ! isset( $map[ $slug ] ) ) {
1188 $map[ $slug ] = $plugin_file;
1189 }
1190 // Also attribute the parent if it isn't
1191 // already attributed and Core doesn't own it.
1192 // Lets a submenu-only plugin (registered
1193 // under a Core parent like `tools.php`) be
1194 // resolvable too.
1195 }
1196 if (
1197 ! in_array( $parent, $before_submenu_keys, true )
1198 && ! isset( $map[ $parent ] )
1199 ) {
1200 $map[ $parent ] = $plugin_file;
1201 }
1202 }
1203 }
1204
1205 return $return;
1206 };
1207
1208 // Preserve the `accepted_args` metadata so callbacks
1209 // expecting parameters from `do_action_ref_array()` still
1210 // receive them. The wrapper uses `func_get_args()` so it
1211 // forwards everything.
1212 $wp_filter['admin_menu']->callbacks[ $priority ][ $id ] = array(
1213 'function' => $wrapper,
1214 'accepted_args' => $accepted_args,
1215 );
1216 }
1217 }
1218 }
1219
1220 add_action( '_admin_menu', 'openstation_install_menu_attribution_tracker', -PHP_INT_MAX );
1221 add_action( '_network_admin_menu', 'openstation_install_menu_attribution_tracker', -PHP_INT_MAX );
1222 add_action( '_user_admin_menu', 'openstation_install_menu_attribution_tracker', -PHP_INT_MAX );
1223
1224 /**
1225 * The subset of `openstation_is_core_menu_slug`'s "core files" that's
1226 * actually owned by Core regardless of any query string — this is what
1227 * we use inside the plugin-file resolver to reject Posts / Pages / etc.
1228 * without rejecting `admin.php?page=…` (a universal plugin signal that
1229 * the public is_core classifier also incorrectly treats as core for
1230 * legacy reasons we don't want to disturb).
1231 *
1232 * The list intentionally drops `admin.php` so plugin-registered
1233 * top-level pages can still be resolved.
1234 *
1235 * @param string $base Slug with query string already stripped.
1236 * @return bool True when the base filename is a Core admin handler.
1237 */
1238 function openstation_is_pure_core_file( $base ) {
1239 $core_files = array(
1240 'index.php',
1241 'edit-comments.php',
1242 'upload.php',
1243 'term.php',
1244 'post-new.php',
1245 'post.php',
1246 'themes.php',
1247 'nav-menus.php',
1248 'widgets.php',
1249 'customize.php',
1250 'plugins.php',
1251 'plugin-install.php',
1252 'plugin-editor.php',
1253 'users.php',
1254 'user-new.php',
1255 'profile.php',
1256 'user-edit.php',
1257 'tools.php',
1258 'import.php',
1259 'export.php',
1260 'site-health.php',
1261 'export-personal-data.php',
1262 'erase-personal-data.php',
1263 'options-general.php',
1264 'options-writing.php',
1265 'options-reading.php',
1266 'options-discussion.php',
1267 'options-media.php',
1268 'options-permalink.php',
1269 'options-privacy.php',
1270 'link-manager.php',
1271 'update-core.php',
1272 );
1273 return in_array( $base, $core_files, true );
1274 }
1275
1276 /**
1277 * Resolve a CPT / taxonomy URL slug (`edit.php?post_type=X` or
1278 * `edit-tags.php?taxonomy=Y`) to the plugin file that registered the
1279 * type. The mapping is built lazily on `init` by capturing the
1280 * filename of whichever code called `register_post_type()` /
1281 * `register_taxonomy()` for non-builtin types.
1282 *
1283 * Returns null when the slug isn't a CPT / taxonomy URL, when the
1284 * registered type is builtin, or when the registrant lives outside
1285 * `WP_PLUGIN_DIR` (theme-registered or mu-plugin).
1286 *
1287 * @param string $slug Menu slug.
1288 * @return string|null Plugin file or null.
1289 */
1290 function openstation_lookup_taxonomy_or_post_type_plugin_file( $slug ) {
1291 if ( false !== strpos( $slug, 'edit.php?' ) && false !== strpos( $slug, 'post_type=' ) ) {
1292 $qs = wp_parse_url( 'http://x/' . ltrim( $slug, '/' ), PHP_URL_QUERY );
1293 parse_str( (string) $qs, $args );
1294 $pt = isset( $args['post_type'] ) ? (string) $args['post_type'] : '';
1295 if ( '' === $pt ) {
1296 return null;
1297 }
1298 $file = openstation_type_registrant_file( $pt, 'post_type' );
1299 return null === $file ? null : openstation_plugin_file_for_path( $file );
1300 }
1301 if ( false !== strpos( $slug, 'edit-tags.php?' ) && false !== strpos( $slug, 'taxonomy=' ) ) {
1302 $qs = wp_parse_url( 'http://x/' . ltrim( $slug, '/' ), PHP_URL_QUERY );
1303 parse_str( (string) $qs, $args );
1304 $tx = isset( $args['taxonomy'] ) ? (string) $args['taxonomy'] : '';
1305 if ( '' === $tx ) {
1306 return null;
1307 }
1308 $file = openstation_type_registrant_file( $tx, 'taxonomy' );
1309 return null === $file ? null : openstation_plugin_file_for_path( $file );
1310 }
1311 return null;
1312 }
1313
1314 /**
1315 * Lazy accessor for the CPT/taxonomy → registering-file map. The map is
1316 * populated by `openstation_record_type_registrant()` (hooked on
1317 * `registered_post_type` / `registered_taxonomy`, which fire during
1318 * `init`), so by the time the dock payload is built — on
1319 * `admin_enqueue_scripts`, well after `init` — every non-builtin type
1320 * registered from an extension has an entry. Stored in a static so
1321 * repeated lookups during a single request don't trigger the populator
1322 * twice.
1323 *
1324 * Values are **absolute filesystem paths**, not plugin files. Core does
1325 * not load `wp-admin/includes/plugin.php` (where `get_plugins()` lives)
1326 * until `wp-admin/admin.php` runs it *after* `wp-load.php` has already
1327 * fired `init` — so a plugin file cannot be resolved at record time.
1328 * Callers resolve the path lazily instead:
1329 * `openstation_lookup_taxonomy_or_post_type_plugin_file()` for the
1330 * dock's plugin attribution, and the My WordPress group resolver for
1331 * the plugin / mu-plugin / theme split.
1332 *
1333 * @return array{post_type: array<string,string>, taxonomy: array<string,string>}
1334 */
1335 function &openstation_get_typed_registrant_map() {
1336 static $map = null;
1337 if ( null === $map ) {
1338 $map = array(
1339 'post_type' => array(),
1340 'taxonomy' => array(),
1341 );
1342 }
1343 return $map;
1344 }
1345
1346 /**
1347 * Read the recorded registering file for a CPT or taxonomy.
1348 *
1349 * @param string $type Type name (CPT or taxonomy).
1350 * @param string $kind Either `'post_type'` or `'taxonomy'`.
1351 * @return string|null Absolute normalized path, or null when unrecorded.
1352 */
1353 function openstation_type_registrant_file( $type, $kind ) {
1354 $map = openstation_get_typed_registrant_map();
1355 return $map[ $kind ][ $type ] ?? null;
1356 }
1357
1358 /**
1359 * Whether this request will ever read the CPT / taxonomy attribution
1360 * map, and is therefore worth paying a `debug_backtrace()` per
1361 * non-builtin type registration to build it.
1362 *
1363 * Only admin-side surfaces consume it: the dock payload (built on
1364 * `admin_enqueue_scripts`) and the site window's section list (built
1365 * on `init`, admin only). A front-end page view registers exactly the
1366 * same types — WooCommerce alone brings several — and would pay the
1367 * whole cost for a map nothing reads.
1368 *
1369 * The predecessor of this function got the same effect by accident:
1370 * it bailed when `get_plugins()` was undefined, which is every
1371 * front-end request. That guard went away when the resolution moved to
1372 * lazy path recording, so the gate is now explicit.
1373 *
1374 * @return bool
1375 */
1376 function openstation_should_track_type_registrants() {
1377 $track = is_admin();
1378
1379 /**
1380 * Filter whether to record which extension registered each CPT and
1381 * taxonomy this request.
1382 *
1383 * The map drives the dock's "Deactivate <plugin>" action and the
1384 * site window's plugin folders. Return true on a front-end request
1385 * only if something there reads it — building it costs one bounded
1386 * backtrace per non-builtin type registration.
1387 *
1388 * **Status: Experimental**
1389 *
1390 * @param bool $track Default: admin requests only.
1391 */
1392 return (bool) apply_filters( 'openstation_track_type_registrants', $track );
1393 }
1394
1395 /**
1396 * Record the registering file for a CPT or taxonomy. Hooked at
1397 * `registered_post_type` / `registered_taxonomy` priority 9999 so we
1398 * fire after every other listener has run (lets a plugin re-register
1399 * its own type on top of someone else's — last writer wins, which
1400 * matches WP's runtime semantics).
1401 *
1402 * Resolution is via `debug_backtrace()`: walk frames until we hit one
1403 * whose `file` lives inside an extension directory (plugins, mu-plugins,
1404 * or a theme root). Cheap — the backtrace is bounded and runs once per
1405 * type registration, all during `init`.
1406 *
1407 * @param string $type_or_post_type Type name (CPT or taxonomy).
1408 * @param string $kind Either `'post_type'` or `'taxonomy'`.
1409 * @return void
1410 */
1411 function openstation_record_type_registrant( $type_or_post_type, $kind ) {
1412 if ( '' === (string) $type_or_post_type ) {
1413 return;
1414 }
1415 if ( ! openstation_should_track_type_registrants() ) {
1416 return;
1417 }
1418 // Skip Core builtin types — they're registered from Core itself
1419 // (Posts, Pages, Categories, …) and the backtrace would never land
1420 // inside WP_PLUGIN_DIR anyway. Cheap pre-filter.
1421 if ( 'post_type' === $kind ) {
1422 $obj = get_post_type_object( $type_or_post_type );
1423 if ( $obj && ! empty( $obj->_builtin ) ) {
1424 return;
1425 }
1426 } elseif ( 'taxonomy' === $kind ) {
1427 $obj = get_taxonomy( $type_or_post_type );
1428 if ( $obj && ! empty( $obj->_builtin ) ) {
1429 return;
1430 }
1431 }
1432
1433 $file = openstation_registrant_file_from_backtrace();
1434 if ( null === $file ) {
1435 return;
1436 }
1437 $map = &openstation_get_typed_registrant_map();
1438 $map[ $kind ][ $type_or_post_type ] = $file;
1439 }
1440
1441 /**
1442 * The extension directories a registration can legitimately come from,
1443 * normalized and trailing-slashed. Anything else (Core itself, a
1444 * drop-in, `wp-config.php`) is not attributable to an extension.
1445 *
1446 * @return string[] Normalized directory prefixes.
1447 */
1448 function openstation_extension_dirs() {
1449 static $dirs = null;
1450 if ( null !== $dirs ) {
1451 return $dirs;
1452 }
1453 $dirs = array();
1454 if ( defined( 'WP_PLUGIN_DIR' ) ) {
1455 $dirs[] = wp_normalize_path( WP_PLUGIN_DIR ) . '/';
1456 }
1457 if ( defined( 'WPMU_PLUGIN_DIR' ) ) {
1458 $dirs[] = wp_normalize_path( WPMU_PLUGIN_DIR ) . '/';
1459 }
1460 foreach ( (array) get_theme_roots() as $theme_root ) {
1461 // `get_theme_roots()` returns roots relative to `wp-content`
1462 // when there's only one; `get_theme_root()` normalizes that.
1463 $dirs[] = wp_normalize_path( get_theme_root( (string) $theme_root ) ) . '/';
1464 }
1465 $dirs = array_values( array_unique( array_filter( $dirs ) ) );
1466 return $dirs;
1467 }
1468
1469 /**
1470 * Walk the current PHP backtrace and return the closest frame that
1471 * lives inside an extension directory (plugin, mu-plugin, or theme).
1472 *
1473 * Frames belonging to OpenStation itself are skipped: this function is
1474 * called from `payload.php`, which is under `WP_PLUGIN_DIR`, so the two
1475 * innermost frames would otherwise match and attribute every registered
1476 * type to us.
1477 *
1478 * Used by the CPT / taxonomy registration tracker to attribute
1479 * `register_post_type()` / `register_taxonomy()` calls without forcing
1480 * Core to load `wp-admin/includes/plugin.php` earlier than it would —
1481 * `get_plugins()` does not exist yet at `init`.
1482 *
1483 * @return string|null Normalized absolute path, or null.
1484 */
1485 function openstation_registrant_file_from_backtrace() {
1486 $self_dir = defined( 'OPENSTATION_DIR' ) ? wp_normalize_path( OPENSTATION_DIR ) : '';
1487 $self_dir = $self_dir ? trailingslashit( $self_dir ) : '';
1488 $dirs = openstation_extension_dirs();
1489 if ( empty( $dirs ) ) {
1490 return null;
1491 }
1492
1493 $bt = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 20 );
1494 foreach ( $bt as $frame ) {
1495 if ( empty( $frame['file'] ) ) {
1496 continue;
1497 }
1498 $norm = wp_normalize_path( (string) $frame['file'] );
1499 if ( '' !== $self_dir && 0 === strpos( $norm, $self_dir ) ) {
1500 continue;
1501 }
1502 $norm = openstation_plugin_link_path( $norm );
1503 foreach ( $dirs as $dir ) {
1504 if ( 0 === strpos( $norm, $dir ) ) {
1505 return $norm;
1506 }
1507 }
1508 }
1509 return null;
1510 }
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
1540 add_action(
1541 'registered_post_type',
1542 static function ( $post_type ) {
1543 openstation_record_type_registrant( $post_type, 'post_type' );
1544 },
1545 9999,
1546 1
1547 );
1548
1549 add_action(
1550 'registered_taxonomy',
1551 static function ( $taxonomy ) {
1552 openstation_record_type_registrant( $taxonomy, 'taxonomy' );
1553 },
1554 9999,
1555 1
1556 );
1557
1558 /**
1559 * Resolve the declaring file of a hook callback. Handles closures,
1560 * `[ $object, 'method' ]`, `[ 'Class', 'method' ]`, plain function names,
1561 * and `'Class::method'` strings. Returns null when reflection fails or
1562 * the callback shape isn't reflectable (rare — e.g. an invocable object
1563 * whose `__invoke` lives in PHP core).
1564 *
1565 * @param mixed $callback A callback as stored in `WP_Hook::$callbacks[$prio][$id]['function']`.
1566 * @return string|null Absolute filesystem path of the declaring file, or null.
1567 */
1568 function openstation_callback_source_file( $callback ) {
1569 if ( empty( $callback ) ) {
1570 return null;
1571 }
1572 try {
1573 if ( is_string( $callback ) && false !== strpos( $callback, '::' ) ) {
1574 list( $class, $method ) = explode( '::', $callback, 2 );
1575 $ref = new ReflectionMethod( $class, $method );
1576 } elseif ( is_array( $callback ) && isset( $callback[0], $callback[1] ) ) {
1577 $ref = new ReflectionMethod( $callback[0], (string) $callback[1] );
1578 } elseif ( is_object( $callback ) && ! ( $callback instanceof Closure ) && method_exists( $callback, '__invoke' ) ) {
1579 $ref = new ReflectionMethod( $callback, '__invoke' );
1580 } elseif ( is_callable( $callback ) ) {
1581 $ref = new ReflectionFunction( $callback );
1582 } else {
1583 return null;
1584 }
1585 $file = $ref->getFileName();
1586 return $file ? $file : null;
1587 } catch ( ReflectionException $e ) {
1588 return null;
1589 }
1590 }
1591
1592 /**
1593 * Resolve whether a given menu slug is rendered in the dock.
1594 * Returns one of two values:
1595 *
1596 * - `'dock'` — render this item on the unified dock rail.
1597 * - `'hidden'` — don't render this item anywhere in the desktop
1598 * shell. The underlying admin menu entry still
1599 * exists server-side; this only suppresses the
1600 * desktop-shell tile.
1601 *
1602 * Default is `'dock'` for every menu item. Plugins + site admins can
1603 * hide individual items via the `openstation_dock_placement` filter.
1604 *
1605 * @param string $menu_slug The menu slug (e.g. `edit.php`, `woocommerce`).
1606 * @return string `'dock'` or `'hidden'`.
1607 */
1608 function openstation_dock_placement( $menu_slug ) {
1609 /**
1610 * Filter whether a specific menu item is shown in the dock.
1611 *
1612 * Return `'dock'` to render the item on the dock (default) or
1613 * `'hidden'` to suppress it entirely. Any other value coerces to
1614 * `'dock'` — a defensive guard so a misbehaving filter can't
1615 * corrupt the dock with `null` / `false` / arbitrary strings.
1616 *
1617 * @param string $placement Default — always `'dock'`.
1618 * @param string $menu_slug The menu slug triggering the lookup.
1619 */
1620 $filtered = apply_filters( 'openstation_dock_placement', 'dock', $menu_slug );
1621 return 'hidden' === $filtered ? 'hidden' : 'dock';
1622 }
1623
1624 /**
1625 * Assemble the menu payload consumed by the shell.
1626 *
1627 * Runs the full dock-builder and returns a single `dockItems` array —
1628 * core WordPress menus first (Dashboard, Posts, Media, …), then
1629 * plugin-contributed top-level menus. Items whose `placement` is
1630 * `'hidden'` are dropped entirely.
1631 *
1632 * Extracted out of `includes/render.php` so both the initial PHP
1633 * localize AND the chromeless bridge's live-refresh emit (including
1634 * the hidden-iframe probe spawned by `wp.os.refreshMenu()`)
1635 * read from a single source of truth — any drift would desync the
1636 * live refresh.
1637 *
1638 * @return array{dockItems: array[]} Menu payload.
1639 */
1640 function openstation_build_menu_payload() {
1641 $all = openstation_build_dock_items();
1642
1643 // Drop hidden items; preserve the default "core first, plugins
1644 // after" ordering by partitioning on the core classifier.
1645 $visible = array_values(
1646 array_filter(
1647 $all,
1648 static function ( $item ) {
1649 return 'hidden' !== ( $item['placement'] ?? 'dock' );
1650 }
1651 )
1652 );
1653
1654 // Partition on the per-item `isCore` flag set in
1655 // openstation_build_dock_items — that classifier ran against the
1656 // raw menu slug ($item[2]), which is what
1657 // openstation_is_core_menu_slug actually compares. The outer 'id'
1658 // field is a sanitized CSS id (e.g. `toplevel_page_jetpack`) and
1659 // would never match.
1660 $core = array();
1661 $plugin = array();
1662 foreach ( $visible as $item ) {
1663 if ( ! empty( $item['isCore'] ) ) {
1664 $core[] = $item;
1665 } else {
1666 $plugin[] = $item;
1667 }
1668 }
1669
1670 $dock = array_merge( $core, $plugin );
1671
1672 // One collector call feeds both halves: the slim entry list and
1673 // the handle-keyed script data the shell joins them with.
1674 $native_windows = openstation_collect_native_windows_payload();
1675
1676 $payload = array(
1677 'dockItems' => $dock,
1678 'nativeWindows' => $native_windows['windows'],
1679 'nativeWindowScriptData' => $native_windows['scriptData'],
1680 );
1681
1682 // Optional per-surface payload builders — each module ships a
1683 // zero-arg `openstation_build_*_payload()`; modules that aren't
1684 // loaded this request contribute an empty array.
1685 $builders = array(
1686 'serverWidgets' => 'openstation_build_desktop_widgets_payload',
1687 'serverWallpapers' => 'openstation_build_desktop_wallpapers_payload',
1688 'serverCommandScripts' => 'openstation_build_desktop_command_scripts_payload',
1689 'serverCommands' => 'openstation_build_desktop_commands_payload',
1690 'serverSettingsTabScripts' => 'openstation_build_desktop_settings_tab_scripts_payload',
1691 'serverSettingsTabs' => 'openstation_build_desktop_settings_tabs_payload',
1692 'serverDockRailRendererScripts' => 'openstation_build_dock_rail_renderer_scripts_payload',
1693 'serverTitleBarButtonScripts' => 'openstation_build_desktop_titlebar_button_scripts_payload',
1694 'serverWindowActionScripts' => 'openstation_build_desktop_window_action_scripts_payload',
1695 'serverUnfocusEffectScripts' => 'openstation_build_desktop_unfocus_effect_scripts_payload',
1696 'serverWindowLinkRendererScripts' => 'openstation_build_window_link_renderer_scripts_payload',
1697 'serverWindowThemeScripts' => 'openstation_build_window_theme_scripts_payload',
1698 'serverWindowThemes' => 'openstation_build_window_themes_payload',
1699 'serverWindowControlScripts' => 'openstation_build_window_control_scripts_payload',
1700 'serverWindowControls' => 'openstation_build_window_controls_payload',
1701 'serverWindowSlotScripts' => 'openstation_build_window_slot_scripts_payload',
1702 'serverWindowSlots' => 'openstation_build_window_slots_payload',
1703 'serverWindowChromeScripts' => 'openstation_build_window_chrome_scripts_payload',
1704 'serverWindowChromes' => 'openstation_build_window_chromes_payload',
1705 'serverWindowNotices' => 'openstation_build_window_notices_payload',
1706 'serverGames' => 'openstation_build_desktop_games_payload',
1707 'serverDesktopThemes' => 'openstation_build_desktop_themes_payload',
1708 'desktopIcons' => 'openstation_build_desktop_icons_payload',
1709 );
1710
1711 foreach ( $builders as $key => $builder ) {
1712 $payload[ $key ] = function_exists( $builder ) ? $builder() : array();
1713 }
1714
1715 // Aggregate update counts for the admin bar's "updates" notifier
1716 // (the circle-arrows badge Core renders top-left). The node is
1717 // static server HTML on the shell page, so after an in-window
1718 // update run the shell needs fresh numbers to repaint it — GH#296.
1719 // `wp_get_update_data()` is capability-aware (plugins / themes /
1720 // core each gated), so the count matches what this user can act
1721 // on. Strings are prebuilt here so the client repaint stays
1722 // locale-correct without shipping translations to JS.
1723 if ( function_exists( 'wp_get_update_data' ) ) {
1724 $update_data = wp_get_update_data();
1725 $update_total = isset( $update_data['counts']['total'] ) ? (int) $update_data['counts']['total'] : 0;
1726
1727 $payload['updateCounts'] = array(
1728 'total' => $update_total,
1729 'formatted' => number_format_i18n( $update_total ),
1730 'text' => sprintf(
1731 /* translators: %s: number of pending updates. */
1732 _n( '%s update available', '%s updates available', $update_total, 'desktop-mode' ),
1733 number_format_i18n( $update_total )
1734 ),
1735 'url' => network_admin_url( 'update-core.php' ),
1736 );
1737 }
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
1746 // A cheap structural fingerprint of the admin menu the shell uses to
1747 // decide whether a live refresh is warranted. Shipped in every full
1748 // payload so the shell can seed / update its last-known signature
1749 // without recomputing it client-side (which would risk drift from
1750 // the server's capability-gated view). See
1751 // openstation_menu_signature().
1752 $payload['menuSig'] = openstation_menu_signature();
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
1759 return $payload;
1760 }
1761
1762 /**
1763 * Cheap structural fingerprint of the current admin menu.
1764 *
1765 * The chromeless bridge emits the *full* menu payload only from the
1766 * handful of pages whose completion commonly mutates the admin menu
1767 * (activation / install / theme switch). That leaves a gap: a custom
1768 * post type registered through a settings-based tool (CPT UI, Pods,
1769 * ACF, …) saves on its own `admin.php?page=…` / `options.php` screen,
1770 * none of which is in that list, so the new top-level menu never
1771 * reaches the live dock until a full browser reload rebuilds the shell
1772 * (GH#325).
1773 *
1774 * Building the full payload on *every* chromeless page just to catch
1775 * that case would be wasteful — most navigations don't touch the menu.
1776 * Instead every chromeless page ships this lightweight signature; the
1777 * shell compares it against its last-known value and only spends a
1778 * `wp.os.refreshMenu()` probe when it actually changed.
1779 *
1780 * The hash covers the capability-passing top-level + submenu slugs and
1781 * their (badge-stripped) titles — i.e. exactly the add / remove /
1782 * rename events the dock cares about. Transient badge counts (update
1783 * notifications, moderation queues) are stripped so they don't churn
1784 * the signature; those have their own refresh path.
1785 *
1786 * @return string 32-char md5 fingerprint, or '' when the menu is
1787 * unavailable (non-admin context).
1788 */
1789 function openstation_menu_signature() {
1790 global $menu, $submenu;
1791
1792 if ( empty( $menu ) || ! is_array( $menu ) ) {
1793 return '';
1794 }
1795
1796 $clean_title = static function ( $raw ) {
1797 // Mirror openstation_build_dock_items(): drop badge spans first,
1798 // then any remaining markup, so update counts don't move the hash.
1799 $stripped = preg_replace( '/<span[^>]*>.*?<\/span>/s', '', (string) $raw );
1800 return trim( wp_strip_all_tags( (string) $stripped ) );
1801 };
1802
1803 $parts = array();
1804
1805 foreach ( $menu as $item ) {
1806 if ( empty( $item[2] ) ) {
1807 continue;
1808 }
1809 if ( ! empty( $item[4] ) && false !== strpos( $item[4], 'wp-menu-separator' ) ) {
1810 continue;
1811 }
1812 if ( ! empty( $item[1] ) && ! current_user_can( $item[1] ) ) {
1813 continue;
1814 }
1815
1816 $slug = (string) $item[2];
1817 $parts[] = $slug . '|' . $clean_title( $item[0] ?? '' );
1818
1819 if ( empty( $submenu[ $slug ] ) || ! is_array( $submenu[ $slug ] ) ) {
1820 continue;
1821 }
1822 foreach ( $submenu[ $slug ] as $sub_item ) {
1823 if ( ! empty( $sub_item[1] ) && ! current_user_can( $sub_item[1] ) ) {
1824 continue;
1825 }
1826 $parts[] = "\t" . ( isset( $sub_item[2] ) ? (string) $sub_item[2] : '' )
1827 . '|' . $clean_title( $sub_item[0] ?? '' );
1828 }
1829 }
1830
1831 return md5( implode( "\n", $parts ) );
1832 }
1833
1834 /**
1835 * A handle's dependency closure, in load order.
1836 *
1837 * Post-order depth-first: a handle is emitted only after everything it
1838 * declares, which is the order `WP_Scripts::do_item()` would have
1839 * printed them in. A handle is marked visited *before* its own
1840 * dependencies are walked, so a dependency cycle unwinds instead of
1841 * recursing forever, and an unregistered handle is skipped rather than
1842 * being fatal — it contributes nothing and stops nothing.
1843 *
1844 * **Deliberately not `WP_Dependencies::all_deps()`.** Three reasons,
1845 * each of which has bitten this codebase:
1846 *
1847 * 1. `WP_Scripts::all_deps()` applies `print_scripts_array` to its
1848 * result whenever `$recursion` is falsy. That filter is where the
1849 * chromeless palette trim and the asset guard live, so resolving a
1850 * payload through it would run a print-time trim across a dependency
1851 * list and let the guard splice this plugin's own bundles into it.
1852 * Called from inside one of those filters it is an infinite loop.
1853 *
1854 * 2. Passing `$recursion = true` silences that filter but changes the
1855 * contract: the first handle that fails aborts the entire call
1856 * (`return false`), abandoning every handle after it in the list. The
1857 * caller is left with a `$to_do` that is a truncated prefix of the real
1858 * closure and indistinguishable from a complete one — a silent, partial
1859 * answer conditional on unrelated registrations elsewhere on the page.
1860 * A lazily-delivered bundle resolved that way loses packages it
1861 * declared and throws on an undefined global at mount, which is the
1862 * exact bug this whole mechanism exists to prevent.
1863 *
1864 * 3. `all_deps()` reports missing dependencies through
1865 * `_doing_it_wrong()`. This is read-only analysis; the real print pass
1866 * raises those anyway, and raising them twice turns someone else's
1867 * pre-existing warning into our noise.
1868 *
1869 * O(V+E) over the graph, allocates one set, and clones nothing.
1870 *
1871 * @param WP_Dependencies $dependencies The scripts or styles registry.
1872 * @param string[] $handles Roots to walk.
1873 * @return string[] Registered handles, dependencies before dependents.
1874 */
1875 function openstation_script_dependency_closure( $dependencies, $handles ) {
1876 $seen = array();
1877 $out = array();
1878 openstation_collect_script_dependency_closure( $dependencies, (array) $handles, $seen, $out );
1879
1880 return $out;
1881 }
1882
1883 /**
1884 * Recursive half of {@see openstation_script_dependency_closure()}.
1885 *
1886 * @param WP_Dependencies $dependencies The scripts or styles registry.
1887 * @param string[] $handles Handles to walk.
1888 * @param array $seen Handle => true, by reference.
1889 * @param string[] $out Ordered result, by reference.
1890 */
1891 function openstation_collect_script_dependency_closure( $dependencies, $handles, &$seen, &$out ) {
1892 foreach ( (array) $handles as $handle ) {
1893 if ( isset( $seen[ $handle ] ) ) {
1894 continue;
1895 }
1896 // Marked BEFORE recursing, so a cycle meets itself as visited
1897 // and unwinds rather than recursing forever.
1898 $seen[ $handle ] = true;
1899 if ( ! isset( $dependencies->registered[ $handle ] ) ) {
1900 continue;
1901 }
1902 openstation_collect_script_dependency_closure(
1903 $dependencies,
1904 $dependencies->registered[ $handle ]->deps,
1905 $seen,
1906 $out
1907 );
1908 $out[] = $handle;
1909 }
1910 }
1911
1912 /**
1913 * Resolve a handle's dependency closure, in load order.
1914 *
1915 * **Why a lazily-delivered handle needs this at all.** WordPress
1916 * normally resolves a script's dependencies when it enqueues it — the
1917 * packages a bundle declares are on the page before its own body runs.
1918 * A handle that is only ever delivered lazily never goes through that:
1919 * `loadVendorScript()` injects one URL, and a bundle declaring
1920 * `wp-api-fetch` found `wp.apiFetch` undefined at mount.
1921 *
1922 * That used to work by accident. Core's ⌘K palette was enqueued on
1923 * every admin page and its closure is the whole Gutenberg runtime, so
1924 * `wp.apiFetch`, `wp.element` and friends happened to be globals.
1925 * Deferring the palette took the accident away and left the contract
1926 * exposed — see `docs/migration-wp-package-globals.md`.
1927 *
1928 * The closure comes from {@see openstation_script_dependency_closure()}
1929 * rather than `WP_Dependencies::all_deps()`; that function's docblock
1930 * records why, and the short version is that `all_deps()` answers a
1931 * question like this one with a silently truncated list. The handle
1932 * itself is excluded — the caller loads it separately, after these.
1933 *
1934 * @param string $handle Script handle.
1935 * @return array<int,array<string,mixed>> Ordered dependency payloads.
1936 */
1937 function openstation_resolve_script_dependencies( $handle ) {
1938 $handle = (string) $handle;
1939 $wp_scripts = wp_scripts();
1940 if ( '' === $handle || ! $wp_scripts || ! isset( $wp_scripts->registered[ $handle ] ) ) {
1941 return array();
1942 }
1943 $deps = $wp_scripts->registered[ $handle ]->deps;
1944 if ( empty( $deps ) ) {
1945 return array();
1946 }
1947
1948 $out = array();
1949 foreach ( openstation_script_dependency_closure( $wp_scripts, $deps ) as $dep_handle ) {
1950 if ( $dep_handle === $handle ) {
1951 continue;
1952 }
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.
1958 if ( '' === $payload['url']
1959 && empty( $payload['before'] )
1960 && empty( $payload['after'] )
1961 && empty( $payload['l10n'] ) ) {
1962 continue;
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`.
1973 $payload['handle'] = (string) $dep_handle;
1974 $out[] = $payload;
1975 }
1976 return $out;
1977 }
1978
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 /**
2074 * Resolve a registered WP script handle into the full payload the
2075 * shell needs to lazy-load it without going through `wp_print_scripts()`.
2076 *
2077 * Returns:
2078 *
2079 * ```
2080 * array(
2081 * 'url' => 'https://…/script.js?ver=…',
2082 * 'before' => array( /* `wp_add_inline_script( $h, $code, 'before' )` strings *\/ ),
2083 * 'after' => array( /* `wp_add_inline_script( $h, $code, 'after' )` strings *\/ ),
2084 * 'l10n' => array( /* `wp_localize_script( $h, $name, $data )` precomputed `<script>var $name = …;</script>` strings *\/ ),
2085 * 'translations' => string, /* `wp_set_script_translations()` JED chunk *\/
2086 * )
2087 * ```
2088 *
2089 * **The `l10n` / `before` / `after` / `translations` fields exist
2090 * because the lazy-load path in the shell appends a raw
2091 * `<script src="…">` and never invokes `wp_print_scripts()` — so any
2092 * `wp_localize_script` / `wp_add_inline_script` / `wp_set_script_translations`
2093 * data attached to the handle would be silently dropped without this
2094 * harvest.** The shell injects each entry as inline `<script>` tags
2095 * around the lazy `<script src>` in the same order
2096 * `WP_Scripts::do_item()` would have used.
2097 *
2098 * Returns an empty payload (`array( 'url' => '' )`) when the handle
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.
2105 *
2106 * Shared between `openstation_register_window()` and
2107 * `openstation_register_widget()` (and every other registration that
2108 * relies on lazy script loading in the shell) because all of them
2109 * need identical handle→payload plumbing to power mid-session dynamic
2110 * script loading without the `wp_print_scripts` lifecycle.
2111 *
2112 * @param string $handle WP script handle.
2113 * @return array{ url:string, before:string[], after:string[], l10n:string[], translations:string } Payload (empty `url` on miss).
2114 */
2115 function openstation_resolve_script_payload( $handle ) {
2116 $empty = array(
2117 'url' => '',
2118 'before' => array(),
2119 'after' => array(),
2120 'l10n' => array(),
2121 'translations' => '',
2122 );
2123
2124 $handle = (string) $handle;
2125 if ( '' === $handle ) {
2126 return $empty;
2127 }
2128 $wp_scripts = wp_scripts();
2129 if ( ! $wp_scripts || ! isset( $wp_scripts->registered[ $handle ] ) ) {
2130 return $empty;
2131 }
2132 $registered = $wp_scripts->registered[ $handle ];
2133 $src = is_string( $registered->src ) ? $registered->src : '';
2134
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 }
2156 }
2157
2158 // Harvest `extra` data the lazy-load path would otherwise drop.
2159 $before = array();
2160 $after = array();
2161 $l10n = array();
2162
2163 if ( isset( $registered->extra['before'] ) && is_array( $registered->extra['before'] ) ) {
2164 foreach ( $registered->extra['before'] as $code ) {
2165 $code = (string) $code;
2166 if ( '' !== $code ) {
2167 $before[] = $code;
2168 }
2169 }
2170 }
2171 if ( isset( $registered->extra['after'] ) && is_array( $registered->extra['after'] ) ) {
2172 foreach ( $registered->extra['after'] as $code ) {
2173 $code = (string) $code;
2174 if ( '' !== $code ) {
2175 $after[] = $code;
2176 }
2177 }
2178 }
2179 // `wp_localize_script` stores its JS at `extra['data']` as a single
2180 // concatenated string of `var x = …;` assignments. We capture it
2181 // verbatim — the shell will eval it as the body of an inline
2182 // `<script>` tag, mirroring what `WP_Scripts::print_extra_script()`
2183 // does at print time.
2184 if ( ! empty( $registered->extra['data'] ) && is_string( $registered->extra['data'] ) ) {
2185 $l10n[] = $registered->extra['data'];
2186 }
2187
2188 // Translations chunk — `wp_set_script_translations()` builds a
2189 // `wp.i18n.setLocaleData( JSON, 'domain' )` snippet that the print
2190 // pipeline emits before the script body. `print_translations(
2191 // $handle, false )` returns the snippet without echoing.
2192 $translations = '';
2193 if ( '' !== $resolved && method_exists( $wp_scripts, 'print_translations' ) ) {
2194 $captured = $wp_scripts->print_translations( $handle, false );
2195 if ( is_string( $captured ) ) {
2196 $translations = $captured;
2197 }
2198 }
2199
2200 return array(
2201 'url' => $resolved,
2202 'before' => $before,
2203 'after' => $after,
2204 'l10n' => $l10n,
2205 'translations' => $translations,
2206 );
2207 }
2208
2209 /**
2210 * Resolves a registered style handle to its print-time URL + harvested
2211 * inline CSS, the styles-side mirror of
2212 * {@see openstation_resolve_script_payload()}.
2213 *
2214 * Why this exists: when a plugin's native window (or window-chrome
2215 * theme/control/slot/chrome) is activated mid-session — i.e. the user
2216 * activates the plugin from inside an open desktop shell — the parent
2217 * shell page already finished `wp_print_styles`. The plugin's
2218 * `admin_enqueue_scripts` callback never ran for it, so its
2219 * stylesheet is missing. The shell's lazy-loader fixes that by
2220 * injecting a `<link rel="stylesheet">` for every entry whose payload
2221 * carries a `styleUrl`.
2222 *
2223 * Captures both the resolved `src` and any `wp_add_inline_style()`
2224 * blobs attached to the handle so the shell can replay the same data
2225 * the print pipeline would have written.
2226 *
2227 * @param string $handle WP style handle.
2228 * @return array{ url:string, inline:string[] } Payload (empty `url` on miss).
2229 */
2230 function openstation_resolve_style_payload( $handle ) {
2231 $empty = array(
2232 'url' => '',
2233 'inline' => array(),
2234 );
2235
2236 $handle = (string) $handle;
2237 if ( '' === $handle ) {
2238 return $empty;
2239 }
2240 $wp_styles = wp_styles();
2241 if ( ! $wp_styles || ! isset( $wp_styles->registered[ $handle ] ) ) {
2242 return $empty;
2243 }
2244 $registered = $wp_styles->registered[ $handle ];
2245 $src = is_string( $registered->src ) ? $registered->src : '';
2246 if ( '' === $src ) {
2247 return $empty;
2248 }
2249
2250 // Normalize relative paths + attach cache-bust ver — same shape as
2251 // the script resolver. Keeps the two helpers symmetric so callers
2252 // don't have to special-case style vs script payloads.
2253 $resolved = $src;
2254 if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) {
2255 $resolved = site_url( $resolved );
2256 }
2257 if ( ! empty( $registered->ver ) ) {
2258 $resolved = add_query_arg( 'ver', $registered->ver, $resolved );
2259 }
2260
2261 // `wp_add_inline_style()` blobs land in `extra['after']` — capture
2262 // them so the shell can emit a `<style>` tag after the `<link>` to
2263 // preserve cascade order with what `WP_Styles::print_inline_style()`
2264 // would have written.
2265 $inline = array();
2266 if ( isset( $registered->extra['after'] ) && is_array( $registered->extra['after'] ) ) {
2267 foreach ( $registered->extra['after'] as $code ) {
2268 $code = (string) $code;
2269 if ( '' !== $code ) {
2270 $inline[] = $code;
2271 }
2272 }
2273 }
2274
2275 return array(
2276 'url' => $resolved,
2277 'inline' => $inline,
2278 );
2279 }
2280
2281 /**
2282 * Build the deferred command-palette asset manifest.
2283 *
2284 * `wp_enqueue_command_palette_assets()` (WP 6.9+) enqueues
2285 * `wp-commands` + `wp-core-commands` and attaches the inline
2286 * `wp.coreCommands.initializeCommandPalette( … )` call that seeds the
2287 * `core/commands` store. Its transitive dependency chain is the whole
2288 * Gutenberg runtime — `wp-block-editor`, `wp-components`, React,
2289 * `wp-core-data`, some forty bundles, ~800 KB gzipped — which the
2290 * shell used to pay on EVERY boot so that the ⌘K palette's baseline
2291 * commands existed if the user ever opened it.
2292 *
2293 * This builder lets Core do exactly what it would have done — the
2294 * menu-command serialization and the inline init included — then
2295 * UNWINDS the enqueue: it snapshots the script/style queues, calls
2296 * the Core function, diffs out the roots it added, restores the
2297 * queues so nothing prints at boot, and resolves the full ordered
2298 * dependency chain on CLONES (the live `$to_do` is never touched).
2299 * Each handle in the chain is harvested into the same
2300 * url/before/after/l10n/translations shape the native-window lazy
2301 * loader uses, and the shell replays the list — in order — the first
2302 * time the palette is invoked (`src/commands/palette-assets.ts`).
2303 *
2304 * Handles with no `src` (pure aggregators) are kept whenever they
2305 * carry inline data; dropping them would lose middleware and locale
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.
2313 *
2314 * Returns `null` on pre-6.9 sites (no Core palette to defer).
2315 *
2316 * @return array{scripts:array<int,array<string,mixed>>,styles:array<int,array<string,mixed>>}|null
2317 */
2318 function openstation_build_command_palette_assets_payload() {
2319 if ( ! function_exists( 'wp_enqueue_command_palette_assets' ) ) {
2320 return null;
2321 }
2322 $scripts = wp_scripts();
2323 $styles = wp_styles();
2324 if ( ! $scripts || ! $styles ) {
2325 return null;
2326 }
2327
2328 // `wp_enqueue_command_palette_assets()` reads `$submenu` without
2329 // guarding the global — initialize defensively (test contexts,
2330 // edge-case admin requests where the menu wasn't built yet).
2331 global $menu, $submenu;
2332 // phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited -- initializing an unset global to its documented empty shape, not replacing a built menu.
2333 if ( ! isset( $submenu ) || ! is_array( $submenu ) ) {
2334 $submenu = array();
2335 }
2336 if ( ! isset( $menu ) || ! is_array( $menu ) ) {
2337 $menu = array();
2338 }
2339 // phpcs:enable WordPress.WP.GlobalVariablesOverride.Prohibited
2340
2341 $script_queue_before = $scripts->queue;
2342 $style_queue_before = $styles->queue;
2343
2344 wp_enqueue_command_palette_assets();
2345
2346 $script_roots = array_values( array_diff( $scripts->queue, $script_queue_before ) );
2347 $style_roots = array_values( array_diff( $styles->queue, $style_queue_before ) );
2348
2349 // Unwind: the boot page must not print any of it. The inline init
2350 // stays attached to the `wp-core-commands` HANDLE — that is the
2351 // point: the harvest below captures it, and if some other screen
2352 // legitimately enqueues the handle, it prints as Core intended.
2353 $scripts->queue = $script_queue_before;
2354 $styles->queue = $style_queue_before;
2355
2356 $out = array(
2357 'scripts' => array(),
2358 'styles' => array(),
2359 );
2360
2361 // Ordered dependency chains, resolved on clones so the request's
2362 // real `$to_do` / `$done` state is untouched.
2363 $script_probe = clone $scripts;
2364 $script_probe->to_do = array();
2365 $script_probe->done = array();
2366 $script_probe->all_deps( $script_roots );
2367 foreach ( $script_probe->to_do as $handle ) {
2368 $payload = openstation_resolve_script_payload( $handle );
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;
2377 }
2378 // Core's `initializeCommandPalette( {…} )` inline embeds the
2379 // serialized admin-menu command list — ~20 KB that the boot
2380 // page ALREADY carries as `window.__openStationMenuCommands`
2381 // (the shell harvester's lookup, attached as a `before`
2382 // inline on the main bundle, and the richer of the two: its
2383 // URL derivation routes legacy file-path slugs through
2384 // `menu_page_url()` where Core's regex takes them literally).
2385 // Ship the list once: strip Core's embedded copy and
2386 // synthesize the same call against the global, which is
2387 // guaranteed present long before the manifest replays — it
2388 // prints at boot, the replay waits for the first ⌘K.
2389 if ( 'wp-core-commands' === $handle ) {
2390 foreach ( array( 'before', 'after' ) as $position ) {
2391 $payload[ $position ] = array_values(
2392 array_filter(
2393 $payload[ $position ],
2394 static function ( $snippet ) {
2395 return false === strpos( (string) $snippet, 'initializeCommandPalette(' );
2396 }
2397 )
2398 );
2399 }
2400 $payload['after'][] = sprintf(
2401 'wp.coreCommands.initializeCommandPalette({"is_network_admin":%s,"menu_commands":window.__openStationMenuCommands||[]});',
2402 is_network_admin() ? 'true' : 'false'
2403 );
2404 }
2405
2406 $out['scripts'][] = array(
2407 'handle' => (string) $handle,
2408 'url' => $payload['url'],
2409 'before' => $payload['before'],
2410 'after' => $payload['after'],
2411 'l10n' => $payload['l10n'],
2412 'translations' => $payload['translations'],
2413 );
2414 }
2415
2416 $style_probe = clone $styles;
2417 $style_probe->to_do = array();
2418 $style_probe->done = array();
2419 $style_probe->all_deps( $style_roots );
2420 foreach ( $style_probe->to_do as $handle ) {
2421 $style_payload = openstation_resolve_style_payload( $handle );
2422 if ( '' === $style_payload['url'] ) {
2423 continue;
2424 }
2425 $out['styles'][] = array(
2426 'handle' => (string) $handle,
2427 'url' => $style_payload['url'],
2428 'inline' => $style_payload['inline'],
2429 );
2430 }
2431
2432 return $out;
2433 }
2434
2435 /**
2436 * Resolve a list of style handles into the `deferredStyles` config
2437 * map: handle → `array( 'url' => …, 'inline' => string[] )`.
2438 *
2439 * For shell surfaces that render on demand but are NOT native
2440 * windows — the Preferences panel, the AI assistant, the bug-report
2441 * window — so the `styles` companion mechanism can't carry their
2442 * CSS. The shell reads this map off `openStationConfig.deferredStyles`
2443 * and injects each sheet the first time its surface opens
2444 * (`ensureDeferredStyle()` in `src/deferred-styles.ts`).
2445 *
2446 * Handles that resolve to nothing (never registered) are dropped, so
2447 * the client map only ever holds injectable entries.
2448 *
2449 * @param string[] $handles Registered style handles.
2450 * @return array<string, array{url:string, inline:string[]}>
2451 */
2452 function openstation_build_deferred_styles( $handles ) {
2453 $out = array();
2454 foreach ( (array) $handles as $handle ) {
2455 $handle = (string) $handle;
2456 $payload = openstation_resolve_style_payload( $handle );
2457 if ( '' === $payload['url'] ) {
2458 continue;
2459 }
2460 $out[ $handle ] = $payload;
2461 }
2462 return $out;
2463 }
2464
2465 /**
2466 * Fire a `_doing_it_wrong()` notice exactly once per handle per
2467 * request. Shared by every `openstation_build_desktop_*_scripts_payload()`
2468 * caller — payload builders run on every shell-config rebuild
2469 * (multiple times per page load via REST + admin-bar refresh +
2470 * tests), so undeduped notices spam the error log AND trip
2471 * `expectedIncorrectUsage` assertions in unrelated tests.
2472 *
2473 * @param string $function_name `openstation_register_*_script` — passed verbatim to `_doing_it_wrong`.
2474 * @param string $kind Human label: `Command`, `Settings-tab`, `Title-bar button`.
2475 * @param string $handle Offending script handle.
2476 */
2477 function openstation_warn_unresolvable_script_handle( $function_name, $kind, $handle ) {
2478 static $warned = array();
2479 $cache_key = $function_name . '|' . $handle;
2480 if ( isset( $warned[ $cache_key ] ) ) {
2481 return;
2482 }
2483 $warned[ $cache_key ] = true;
2484
2485 if ( '__flush__' === $handle ) {
2486 // Test escape hatch: clear the dedupe cache so a flush
2487 // helper can reset between tests.
2488 $warned = array();
2489 return;
2490 }
2491
2492 _doing_it_wrong(
2493 esc_html( $function_name ),
2494 sprintf(
2495 /* translators: 1: kind ("Command"/"Settings-tab"/"Title-bar button"), 2: handle. */
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' ),
2497 esc_html( $kind ),
2498 esc_html( $handle )
2499 ),
2500 '0.8.1'
2501 );
2502 }
2503
2504 /**
2505 * Test-only: clear every script-handle registry + the dedupe
2506 * cache for the unresolvable-handle notice. Tests call this in
2507 * `set_up` so prior tests' synthetic handles can't leak into
2508 * later assertions about payload shape.
2509 */
2510 function openstation_flush_script_handle_registries() {
2511 $flushers = array(
2512 'openstation_flush_desktop_command_script_registry',
2513 'openstation_flush_desktop_settings_tab_script_registry',
2514 'openstation_flush_dock_rail_renderer_script_registry',
2515 'openstation_flush_desktop_titlebar_button_script_registry',
2516 'openstation_flush_desktop_window_action_script_registry',
2517 'openstation_flush_desktop_unfocus_effect_script_registry',
2518 'openstation_flush_window_link_renderer_script_registry',
2519 'openstation_flush_window_theme_script_registry',
2520 'openstation_flush_window_theme_registry',
2521 'openstation_flush_window_control_script_registry',
2522 'openstation_flush_window_control_registry',
2523 'openstation_flush_window_slot_script_registry',
2524 'openstation_flush_window_slot_registry',
2525 'openstation_flush_window_chrome_script_registry',
2526 'openstation_flush_window_chrome_registry',
2527 'openstation_flush_window_notice_registry',
2528 );
2529
2530 foreach ( $flushers as $flusher ) {
2531 if ( function_exists( $flusher ) ) {
2532 $flusher();
2533 }
2534 }
2535
2536 openstation_warn_unresolvable_script_handle( '', '', '__flush__' );
2537 }
2538
2539 /**
2540 * Collect the native-window payload: slim per-window entries plus a
2541 * handle-keyed script-data map.
2542 *
2543 * For each entry registered via `openstation_register_window()` the
2544 * `windows` list captures the window's metadata
2545 * (id/title/icon/placement/dimensions/autofocus), the rendered
2546 * template HTML, and the HANDLE NAMES of its script, companions and
2547 * tab scripts. The resolved data those handles stand for — URL plus
2548 * harvested `wp_localize_script` / `wp_add_inline_script` /
2549 * translations, see `openstation_resolve_script_payload()` — lives
2550 * ONCE per handle in `scriptData`, and the shell joins the two on
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.
2556 *
2557 * The split exists because script data is a property of the HANDLE,
2558 * not of the window: every App Framework window rides
2559 * `openstation-app-runtime`, and inlining each entry's resolved copy
2560 * serialized the same localize blobs and the same shared config set
2561 * four times over — `scriptL10n` alone was ~100 KB of the boot
2562 * payload, most of it repetition. The synthesized
2563 * `openStationWindowConfig[ id ]` assignments group by handle for
2564 * the same reason they used to ride every sharing entry: the shell
2565 * fetches a URL once, and a bundle can serve one window from inside
2566 * another (the Users window mounts the Profile form, which reads the
2567 * user-edit config), so whichever entry loads the bundle must
2568 * deliver the whole handle's config set.
2569 *
2570 * Style data stays inline on the entries — it never had a
2571 * duplication problem worth a second map ( companion styles across
2572 * the whole registry total ~2 KB ).
2573 *
2574 * @return array{windows:array[],scriptData:array<string,array{url:string,before:string[],after:string[],l10n:string[],translations:string,deps:string[]}>}
2575 */
2576 function openstation_collect_native_windows_payload() {
2577 $empty = array(
2578 'windows' => array(),
2579 'scriptData' => array(),
2580 );
2581 if ( ! function_exists( 'openstation_native_window_registry' ) ) {
2582 return $empty;
2583 }
2584
2585 $registry = openstation_native_window_registry();
2586 if ( ! is_array( $registry ) ) {
2587 return $empty;
2588 }
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
2606 $script_data = array();
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
2616 // Resolve a handle into the map, once. Returns the handle when it
2617 // resolved to something loadable, '' when it did not (never
2618 // registered, no src) — the same silent drop the inline shape
2619 // applied to companions and tab scripts.
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 ) {
2633 $handle = (string) $handle;
2634 if ( '' === $handle ) {
2635 return '';
2636 }
2637 if ( isset( $resolved_as_bundle[ $handle ] ) ) {
2638 return $resolved_as_bundle[ $handle ];
2639 }
2640 $payload = isset( $script_data[ $handle ] )
2641 ? $script_data[ $handle ]
2642 : openstation_resolve_script_payload( $handle );
2643 if ( '' === $payload['url'] ) {
2644 $resolved_as_bundle[ $handle ] = '';
2645 return '';
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;
2659 $script_data[ $handle ] = $payload;
2660 return $handle;
2661 };
2662
2663 // Synthesized `openStationWindowConfig[ id ]` assignments, grouped
2664 // by script handle (see the function docblock). Collected first so
2665 // they can be appended to each handle's map entry exactly once,
2666 // after its own harvested data — the same order the print pipeline
2667 // would have used.
2668 $config_snippets_by_handle = array();
2669 foreach ( $registry as $entry ) {
2670 $handle = isset( $entry['script'] ) ? (string) $entry['script'] : '';
2671 if ( '' === $handle || ! is_callable( $entry['template'] ) ) {
2672 continue;
2673 }
2674 $window_config = openstation_filter_native_window_config( $entry );
2675 if ( empty( $window_config ) ) {
2676 continue;
2677 }
2678 $config_snippets_by_handle[ $handle ][ $entry['id'] ] = sprintf(
2679 'window.openStationWindowConfig=window.openStationWindowConfig||{};window.openStationWindowConfig[%s]=%s;',
2680 wp_json_encode( $entry['id'] ),
2681 wp_json_encode( $window_config )
2682 );
2683 }
2684
2685 $out = array();
2686 foreach ( $registry as $entry ) {
2687 if ( ! is_callable( $entry['template'] ) ) {
2688 continue;
2689 }
2690
2691 // Capture the template HTML (tab-wrapped when any
2692 // additional tabs are registered via
2693 // `openstation_register_window_tab()`; flat otherwise).
2694 // Captured as a string so the shell can inject it as a
2695 // `<template>` at mid-session plugin activation without a
2696 // reload.
2697 $template_html = openstation_build_native_window_template_html( $entry );
2698
2699 // `$collect_handle()` answers "is there a bundle to fetch?", and
2700 // returns '' when the handle resolves to no URL — a src-less
2701 // alias handle registered only to carry `preload_script` or
2702 // inline data, for instance. That is the right answer for
2703 // `scriptHandle`, which names something to load. It is the
2704 // wrong answer for `ownerHandle`, which names WHO the window
2705 // belongs to: attribution does not depend on whether the owner
2706 // happens to ship a file. Shipping '' there broke the
2707 // documented "always populated" contract and blanked
2708 // `wp.os.debug.window()`.
2709 $declared_script = isset( $entry['script'] ) ? (string) $entry['script'] : '';
2710 $script_handle = $collect_handle( $declared_script );
2711 $owner_handle = '' !== $script_handle ? $script_handle : $declared_script;
2712
2713 // Companion handles (`scripts` arg) — bundles that extend the
2714 // window from outside it and must be in the tab before its
2715 // render callback paints. Kept as an ordered handle list; the
2716 // shell loads them in declared order ahead of the window's
2717 // own script, resolving each through `scriptData`.
2718 $companion_scripts = array();
2719 if ( ! empty( $entry['scripts'] ) && is_array( $entry['scripts'] ) ) {
2720 foreach ( $entry['scripts'] as $companion_handle ) {
2721 $companion_handle = $collect_handle( $companion_handle );
2722 if ( '' !== $companion_handle ) {
2723 $companion_scripts[] = $companion_handle;
2724 }
2725 }
2726 }
2727
2728 // Resolve the optional style handle alongside the script so the
2729 // shell's lazy-loader can inject a `<link rel="stylesheet">`
2730 // (and any `wp_add_inline_style()` blobs) on mid-session
2731 // activation. Empty payload when no handle was declared OR the
2732 // handle isn't registered — both treated as "no styles to load."
2733 $style_handle = isset( $entry['style'] ) ? (string) $entry['style'] : '';
2734 $style_payload = openstation_resolve_style_payload( $style_handle );
2735
2736 // Companion style handles (`styles` arg) — stylesheets the
2737 // shell injects on the window's FIRST OPEN, after the window's
2738 // own style, in declared order. The styles-side mirror of
2739 // `companionScripts`, with different timing on purpose: the
2740 // window's own `style` lands when the window registers so a
2741 // mid-session activation paints, but a companion exists to be
2742 // deferred — it costs nothing until the window is actually
2743 // shown. Unregistered handles drop, same as script companions.
2744 $companion_styles = array();
2745 if ( ! empty( $entry['styles'] ) && is_array( $entry['styles'] ) ) {
2746 foreach ( $entry['styles'] as $companion_style_handle ) {
2747 $companion_style_handle = (string) $companion_style_handle;
2748 $companion_style_payload = openstation_resolve_style_payload( $companion_style_handle );
2749 if ( '' === $companion_style_payload['url'] ) {
2750 continue;
2751 }
2752 $companion_styles[] = array(
2753 'styleUrl' => $companion_style_payload['url'],
2754 'styleHandle' => $companion_style_handle,
2755 'styleInline' => $companion_style_payload['inline'],
2756 );
2757 }
2758 }
2759
2760 // Tab metadata ships alongside the template so the shell can
2761 // render a picker UI, and each tab's script handle joins the
2762 // map so a late tab activation can still load its bundle.
2763 $tab_descriptors = array();
2764 if ( function_exists( 'openstation_get_native_window_tabs' ) ) {
2765 foreach ( openstation_get_native_window_tabs( $entry['id'] ) as $tab ) {
2766 $tab_descriptors[] = array(
2767 'value' => $tab['value'],
2768 'label' => $tab['label'],
2769 'isMain' => $tab['is_main'],
2770 'scriptHandle' => $collect_handle( $tab['script'] ),
2771 );
2772 }
2773 }
2774
2775 $out[] = array(
2776 'id' => $entry['id'],
2777 'title' => $entry['title'],
2778 'icon' => $entry['icon'],
2779 'placement' => $entry['placement'],
2780 // `'app'` or `'control'` — the navigation kind, which
2781 // decides the launcher's default placement and its dock
2782 // zone. See `src/nav/defaults.ts`.
2783 'navKind' => isset( $entry['nav_kind'] ) ? $entry['nav_kind'] : 'app',
2784 // Sort key among system tiles. Absent / 0 puts a plugin's
2785 // launcher ahead of the shell's own trailing cluster.
2786 'dockOrder' => isset( $entry['dock_order'] ) ? (int) $entry['dock_order'] : 0,
2787 'placeable' => ! empty( $entry['placeable'] ),
2788 'width' => $entry['width'],
2789 'height' => $entry['height'],
2790 'minWidth' => $entry['min_width'],
2791 'minHeight' => $entry['min_height'],
2792 'autofocus' => $entry['autofocus'],
2793 'templateId' => 'os-native-window-' . $entry['id'],
2794 'templateHtml' => $template_html,
2795 'scriptHandle' => $script_handle,
2796 'ownerHandle' => $owner_handle,
2797 'companionScripts' => $companion_scripts,
2798 // Whether the shell loads the bundle at boot rather than on
2799 // first open. Off by default: a window's script is dead
2800 // weight on every admin page until the window is actually
2801 // opened.
2802 'preloadScript' => ! empty( $entry['preload_script'] ),
2803 'styleUrl' => $style_payload['url'],
2804 'styleHandle' => $style_handle,
2805 'styleInline' => $style_payload['inline'],
2806 'companionStyles' => $companion_styles,
2807 'tabs' => $tab_descriptors,
2808 'menuPages' => isset( $entry['menu_pages'] ) ? array_values( (array) $entry['menu_pages'] ) : array(),
2809 );
2810 }
2811
2812 // Append each handle's synthesized config set to its map entry —
2813 // once, after the handle's own harvested data. The snippets land
2814 // in REGISTRY-ITERATION order for every consumer of the handle;
2815 // the old per-entry shape put each window's own config first, an
2816 // ordering nothing could observe (each snippet assigns a distinct
2817 // `openStationWindowConfig[ id ]` key and none reads another), so
2818 // it is deliberately not preserved. Configs for handles that
2819 // resolved to nothing are undeliverable and drop, exactly as they
2820 // always did.
2821 foreach ( $config_snippets_by_handle as $handle => $snippets ) {
2822 if ( ! isset( $script_data[ $handle ] ) ) {
2823 continue;
2824 }
2825 foreach ( $snippets as $snippet ) {
2826 $script_data[ $handle ]['l10n'][] = $snippet;
2827 }
2828 }
2829
2830 return array(
2831 'windows' => $out,
2832 'scriptData' => $script_data,
2833 );
2834 }
2835
2836 /**
2837 * The `windows` half of {@see openstation_collect_native_windows_payload()}.
2838 *
2839 * Kept as the historical entry point — tests and older call sites
2840 * ask for the entry list alone. Anything that also needs the
2841 * script-data map (everything that actually LOADS a bundle) should
2842 * call the collector and take both halves from one build.
2843 *
2844 * @return array[]
2845 */
2846 function openstation_build_native_windows_payload() {
2847 $bundle = openstation_collect_native_windows_payload();
2848 return $bundle['windows'];
2849 }
2850
2851 /**
2852 * Cleans a `$menu` / `$submenu` title for display.
2853 *
2854 * Strips badge spans first (`<span class="update-plugins count-3">`),
2855 * then any remaining markup. An empty result means the entry has no
2856 * usable label: plugins register `menu_title => null` to keep a page
2857 * reachable while hiding its row from classic admin's left menu, and
2858 * those must not become tabs.
2859 *
2860 * Shared so everything deciding "is this a visible tab?" agrees.
2861 * {@see openstation_chromeless_submenu_tab_urls()} hides an in-page
2862 * button on the strength of a tab existing, so a divergence here would
2863 * hide a button with nothing on screen to replace it.
2864 *
2865 * @param string $raw_title Raw `$menu[$i][0]` / `$submenu[$p][$i][0]` value.
2866 * @return string Cleaned title, empty when there is none.
2867 */
2868 function openstation_menu_item_title( $raw_title ) {
2869 $stripped = preg_replace( '/<span[^>]*>.*?<\/span>/s', '', (string) $raw_title );
2870
2871 return trim( wp_strip_all_tags( $stripped ) );
2872 }
2873
2874 /**
2875 * Determines whether a menu slug references a real file under `wp-admin/`.
2876 *
2877 * Mirrors the decision core's `wp-admin/menu-header.php` makes when
2878 * linking menu items: strip the query portion, then check whether the
2879 * remaining path exists inside `wp-admin/`. Two registered-slug shapes
2880 * hinge on this distinction:
2881 *
2882 * - URL-style slugs — ACF registers its top-level menu as
2883 * `edit.php?post_type=acf-field-group` via `add_menu_page()`. The
2884 * slug lands in `$_parent_pages`, but `edit.php` is a real admin
2885 * file: classic admin links it directly, and routing it through
2886 * `admin.php?page=…` makes core's dispatcher `wp_die()` with
2887 * "Cannot load edit.php?post_type=acf-field-group."
2888 * - Legacy file-path slugs — WP-Sweep registers
2889 * `wp-sweep/admin.php` via `add_management_page()`. No such file
2890 * exists under `wp-admin/`, so it must resolve as a plugin page
2891 * (`tools.php?page=wp-sweep/admin.php`).
2892 *
2893 * @param string $slug The raw menu item slug.
2894 * @return bool True when the query-stripped slug is a file under `wp-admin/`.
2895 */
2896 function openstation_is_admin_file_slug( $slug ) {
2897 $file = $slug;
2898 $pos = strpos( $file, '?' );
2899 if ( false !== $pos ) {
2900 $file = substr( $file, 0, $pos );
2901 }
2902
2903 if ( '' === $file || 0 !== validate_file( $file ) ) {
2904 return false;
2905 }
2906
2907 return file_exists( ABSPATH . 'wp-admin/' . $file );
2908 }
2909
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 /**
2939 * Converts a menu item slug to a full admin URL.
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 *
2945 * Handles three slug shapes:
2946 * 1. Direct file references (`edit.php`, `upload.php`) — passed
2947 * through `openstation_menu_admin_url()` as-is.
2948 * 2. Plain plugin page slugs (`my-plugin`) — routed through
2949 * `admin.php?page=<slug>` with the slug `rawurlencode()`d.
2950 * 3. Plugin page slugs that embed extra query parameters
2951 * (`wc-admin&path=/customers`) — split on the first `&`, the
2952 * page portion is `rawurlencode()`d, the trailing query is
2953 * reparsed and reassembled with `add_query_arg()` so each
2954 * value is encoded once and the `&` separators are preserved.
2955 *
2956 * The third shape is unusual but legal — WordPress's
2957 * `add_submenu_page()` accepts a slug containing query
2958 * parameters and routes them through `admin.php`. WooCommerce
2959 * uses this pattern for every wc-admin React route
2960 * (`Customers`, `Analytics`, `Marketing`). Without the split
2961 * branch the entire string gets `rawurlencode()`d into the
2962 * `page` parameter, mangling `&` to `%26` and `=` to `%3D` —
2963 * WC's router never sees `path` and the page renders blank.
2964 *
2965 * Returns an `esc_url_raw()`-sanitized URL — these URLs flow
2966 * into the dock JS payload (JSON-encoded, then assigned to
2967 * `iframe.src` / `window.location.href`), not into HTML
2968 * attributes. Using `esc_url()` would emit `&#038;` for the `&`
2969 * separators, which the browser does NOT decode in JS string
2970 * contexts — the resulting iframe load would treat `&#038;path`
2971 * as a literal query key and miss the `path` parameter, sending
2972 * WC's router back to home instead of the requested route.
2973 *
2974 * @param string $slug The menu item slug or URL.
2975 * @return string The full admin URL, sanitized via `esc_url_raw()`.
2976 */
2977 function openstation_menu_item_url( $slug ) {
2978 // Already a full URL.
2979 if ( str_starts_with( $slug, 'http://' ) || str_starts_with( $slug, 'https://' ) ) {
2980 return esc_url_raw( $slug );
2981 }
2982
2983 // Strip path traversal sequences.
2984 $slug = str_replace( '..', '', $slug );
2985
2986 global $_parent_pages;
2987
2988 // Direct file reference (e.g., 'edit.php', 'upload.php') — but
2989 // NOT a registered plugin page that merely looks like one.
2990 // Legacy file-path slugs (WP-Sweep's 'wp-sweep/admin.php',
2991 // registered via add_management_page()) contain '.php' yet are
2992 // page slugs, not admin-root files; `$_parent_pages` is keyed by
2993 // the raw registered slug, so a hit there routes the slug to the
2994 // canonical resolver below (→ `tools.php?page=wp-sweep/admin.php`,
2995 // byte-identical to what core's menu_page_url() builds) instead
2996 // of a 404 at `admin_url( 'wp-sweep/admin.php' )`.
2997 //
2998 // The reverse also happens: URL-style slugs registered through
2999 // `add_menu_page()` / `add_submenu_page()` (ACF's
3000 // 'edit.php?post_type=acf-field-group') sit in `$_parent_pages`
3001 // too, yet reference a real `wp-admin/` file — those must stay
3002 // direct links, or core's `admin.php` dispatcher dies with
3003 // "Cannot load edit.php?post_type=acf-field-group." The admin-
3004 // file check wins over the registration check, same as classic
3005 // admin's `menu-header.php`.
3006 if (
3007 false !== strpos( $slug, '.php' ) &&
3008 ( ! isset( $_parent_pages[ $slug ] ) || openstation_is_admin_file_slug( $slug ) )
3009 ) {
3010 return esc_url_raw( openstation_menu_admin_url( $slug ) );
3011 }
3012
3013 // Plugin page slug with embedded query parameters
3014 // (e.g., 'wc-admin&path=/customers'). Split the page slug from
3015 // the trailing args; we'll resolve the page slug below and
3016 // layer the args back on at the end. This avoids the naive
3017 // `rawurlencode()` packing the `&` separator into `%26`.
3018 $extra_args = array();
3019 if ( false !== strpos( $slug, '&' ) ) {
3020 list( $slug, $tail ) = array_pad( explode( '&', $slug, 2 ), 2, '' );
3021 if ( '' !== $tail ) {
3022 parse_str( $tail, $extra_args );
3023 }
3024 }
3025
3026 // Plain page slug — defer to WordPress's canonical resolver.
3027 //
3028 // `$_parent_pages` is the same global `menu_page_url()` reads;
3029 // we mirror its 4-line decision tree directly so we can return
3030 // a `esc_url_raw`-style raw URL (the `menu_page_url()` helper
3031 // runs its result through `esc_url()`, which entity-encodes the
3032 // `&` separators we need to keep raw for the downstream
3033 // `add_query_arg()` and the JS slug compare).
3034 //
3035 // Resolution rules, identical to core:
3036 // 1. Slug registered under a `.php` parent that itself isn't
3037 // a parent (Tools → `tools.php?page=…`, Settings →
3038 // `options-general.php?page=…`).
3039 // 2. Slug registered as a top-level menu, OR under a slug-
3040 // based parent (WC: `woocommerce` → `admin.php?page=…`).
3041 // 3. Slug not registered at all → fall back to `admin.php`
3042 // so the URL still targets a real dispatcher (matches the
3043 // pre-resolver behavior callers depended on).
3044 $host = 'admin.php?page=' . rawurlencode( $slug );
3045 if ( isset( $_parent_pages[ $slug ] ) ) {
3046 $parent_slug = $_parent_pages[ $slug ];
3047 if ( $parent_slug && ! isset( $_parent_pages[ $parent_slug ] ) ) {
3048 $host = add_query_arg( 'page', $slug, $parent_slug );
3049 }
3050 }
3051
3052 $url = openstation_menu_admin_url( $host );
3053 if ( ! empty( $extra_args ) ) {
3054 $url = add_query_arg( $extra_args, $url );
3055 }
3056 return esc_url_raw( $url );
3057 }
3058