PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / core / payload.php

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

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