PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.3
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.3
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.3, at includes/core/payload.php

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