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

1,953 lines 73.6 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 $title = openstation_menu_item_title( $item[0] );
59
60 // Extract badge count from the title HTML.
61 $badge = 0;
62 if ( preg_match( '/class="(?:update-plugins|awaiting-mod)[^"]*count-(\d+)"/', $item[0], $matches ) ) {
63 $badge = (int) $matches[1];
64 }
65
66 // The Plugins menu badge in `wp-admin/menu.php` is built from
67 // `count( $update_plugins->response )` — a raw transient count
68 // that can include orphan rows (deleted plugin files, entries
69 // injected by third-party update servers for plugins that
70 // aren't installed locally). Our Plugins window's "Update
71 // available" filter only counts updates whose key intersects
72 // `get_plugins()`, because every row in the window comes from
73 // REST `/wp/v2/plugins` which iterates `get_plugins()`.
74 // Recompute the dock badge from the same intersection so the
75 // dock count always agrees with what the window shows (GH#258).
76 if (
77 'plugins.php' === $item[2] &&
78 ! is_multisite() &&
79 function_exists( 'openstation_plugins_window_count_visible_updates' )
80 ) {
81 $badge = openstation_plugins_window_count_visible_updates();
82 }
83
84 // Determine the icon. Menu entries can set `$item[6]` to anything
85 // — a dashicon class, a remote URL, a data:URI, 'none', or 'div'
86 // — so normalize before we serialize it for the shell JS.
87 $icon = openstation_sanitize_dock_icon( $item[6] ?? '' );
88
89 // Build the full URL for the menu item.
90 //
91 // `$parent_url` is the slug-derived URL (`admin.php?page=<slug>`
92 // for plugin pages, the file path for Core ones). It's the
93 // reference value the self-link strip below compares against.
94 // The effective `$url` we ship to the shell can be rewritten
95 // further down to the first visible submenu's URL — see the
96 // note after the loop.
97 $parent_url = openstation_menu_item_url( $item[2] );
98 $url = $parent_url;
99
100 // Build submenu items.
101 //
102 // WordPress auto-prepends a self-link entry to every parent
103 // menu's `$submenu[$slug]` (the first child shares the parent's
104 // slug + URL — that's what `add_menu_page()` generates so the
105 // admin UI can render a clickable parent in the sidebar). For
106 // the shell's JS surface we strip this entry so:
107 //
108 // - `submenu.length === 0` reliably means "no real children"
109 // (the right-click submenu popover stays suppressed; the
110 // in-window tab strip stays hidden).
111 // - `submenu.length > 0` reliably means "has real child links"
112 // — every entry points at a distinct URL.
113 //
114 // Detection by URL (post-`openstation_menu_item_url()` normalize)
115 // rather than slug equality covers plugins that register a child
116 // at a different slug pointing at the parent's URL.
117 $sub_items = array();
118 $first_visible_sub_url = null;
119 $self_label = '';
120 if ( ! empty( $submenu[ $item[2] ] ) ) {
121 foreach ( $submenu[ $item[2] ] as $sub_item ) {
122 if ( ! empty( $sub_item[1] ) && ! current_user_can( $sub_item[1] ) ) {
123 continue;
124 }
125 // No `hide-if-no-customize` filter here. WordPress tags
126 // Appearance → Customize / Header / Background with that
127 // class; the semantics are "shown by default; hide only
128 // when `<body class=\"no-customize-support\">`". The
129 // Customizer is supported inside chromeless iframes, so
130 // these entries belong in the dock.
131 $sub_url = openstation_menu_item_url( $sub_item[2] );
132 // Capture the first capability-passing submenu URL so
133 // we can use it as the parent's effective URL below
134 // (mirrors `wp-admin/menu-header.php`). Captured BEFORE
135 // the self-link strip so plugins whose first submenu IS
136 // the auto-prepended self-link land on the parent URL
137 // (a no-op rewrite — preserves existing behavior).
138 if ( null === $first_visible_sub_url ) {
139 $first_visible_sub_url = $sub_url;
140 }
141 // Self-link strip — `$sub_url === $parent_url` covers
142 // WP's auto-prepended entry AND any plugin-registered
143 // alias that happens to land on the parent URL.
144 if ( $sub_url === $parent_url ) {
145 // Keep its LABEL, though. The stripped entry is a
146 // real row in wp-admin's own menu ("All Posts",
147 // "All Pages"), and the constellation flyout lists
148 // it as the first thing the menu opens — a list of
149 // a menu's pages that omits its main page reads as
150 // a bug.
151 //
152 // Carried separately rather than left in `submenu`
153 // because `submenu` has two other consumers that
154 // need it to mean "distinct child links only": the
155 // in-window tab strip, which would grow a duplicate
156 // first tab, and the right-click popover, which is
157 // suppressed on `length === 0`.
158 //
159 // First one only — a plugin can register several
160 // aliases onto the parent URL, and the canonical
161 // self-link is the one WordPress prepends.
162 if ( '' === $self_label ) {
163 $self_label = openstation_menu_item_title( $sub_item[0] );
164 }
165 continue;
166 }
167 // Skip entries with no resolvable title. Plugins (e.g.
168 // WooCommerce's `wc-addons` Extensions row) register
169 // `menu_title => null` to hide a row from classic admin's
170 // left menu while keeping the page reachable. Without
171 // this guard the dock renders an empty, label-less tab
172 // that visually duplicates a sibling entry.
173 $sub_title = openstation_menu_item_title( $sub_item[0] );
174 if ( '' === $sub_title ) {
175 continue;
176 }
177 $sub_items[] = array(
178 'title' => $sub_title,
179 'url' => $sub_url,
180 );
181 }
182 }
183
184 // Mirror `wp-admin/menu-header.php`: when a parent menu has any
185 // visible submenu, classic admin rewrites the parent's
186 // clickable URL to the first submenu's URL. Plugins like
187 // WooCommerce rely on this — their top-level slug
188 // (`woocommerce`) has no working callback and 500s when hit
189 // directly. The real landing page is the first submenu
190 // (`?page=wc-admin` for WC). Without this rewrite the dock
191 // icon points users at a broken URL that classic admin would
192 // never have linked to.
193 if ( null !== $first_visible_sub_url ) {
194 $url = $first_visible_sub_url;
195 }
196
197 $dock_item = array(
198 'id' => sanitize_key( $item[5] ?? $item[2] ),
199 'title' => $title,
200 'icon' => $icon,
201 'url' => $url,
202 'badge' => $badge,
203 'submenu' => $sub_items,
204 // Label of the stripped self-link ("All Posts"), for
205 // surfaces that list a menu's pages and want its main page
206 // named the way wp-admin names it. Empty when the menu had
207 // no self-link to strip.
208 'selfLabel' => $self_label,
209 'multi' => openstation_dock_item_is_multi( $item[2] ),
210 'placement' => openstation_dock_placement( $item[2] ),
211 'isCore' => openstation_is_core_menu_slug( $item[2] ),
212 'pluginFile' => openstation_resolve_menu_plugin_file( $item[2] ),
213 'pluginName' => null,
214 );
215 if ( $dock_item['pluginFile'] ) {
216 $dock_item['pluginName'] = openstation_plugin_display_name( $dock_item['pluginFile'] );
217 }
218
219 /**
220 * Filters a single dock item's data.
221 *
222 * @param array $dock_item The dock item data.
223 * @param string $menu_slug The menu slug.
224 */
225 $dock_item = apply_filters( 'openstation_dock_item', $dock_item, $item[2] );
226
227 $items[] = $dock_item;
228 }
229
230 /**
231 * Filters the dock items before they are passed to JavaScript.
232 *
233 * @param array[] $items Array of dock item arrays.
234 */
235 return apply_filters( 'openstation_dock_items', $items );
236 }
237
238 /**
239 * Sanitizes a dock icon value for safe injection into the shell JS.
240 *
241 * Menu items can set their icon to one of:
242 *
243 * - A Dashicons class (e.g. `dashicons-admin-post`)
244 * - An http/https URL pointing at an image asset
245 * - A `data:image/svg+xml;base64,…` URI (common for plugins that
246 * ship inline vector art — Jetpack, WooCommerce, etc.). Rendered
247 * as a CSS background-image, where per-spec SVG script content
248 * does not execute, so the surface is safe.
249 * - `'none'` or `'div'` (CSS hooks, no icon asset). The dock's JS
250 * layer extracts the real icon from the hidden `#adminmenu` DOM
251 * for these cases.
252 *
253 * Inline SVG data URIs (`data:image/svg+xml;base64,…` and
254 * `data:image/svg+xml,…`) are also accepted because that's how the
255 * vast majority of WP plugins ship their menu icon — Yoast,
256 * WooCommerce, Jetpack, Elementor, et al. all register `$menu[$i][6]`
257 * as an SVG data URI. Other `data:` schemes (`data:text/html`,
258 * `data:application/javascript`, …) and raw `javascript:` / `vbscript:`
259 * / `file:` schemes remain rejected. The shell renders the SVG via a
260 * CSS `background-image`, which (per the modern browser security model
261 * shared with `<img>`) sandboxes scripts inside the SVG so they do not
262 * execute.
263 *
264 * The return value is always a string safe to drop into an `img.src`,
265 * a CSS class, or a CSS `url()` background without further escaping.
266 *
267 * @param mixed $icon Raw icon value from the menu registration.
268 * @return string Sanitized icon string.
269 */
270 function openstation_sanitize_dock_icon( $icon ) {
271 $fallback = 'dashicons-admin-generic';
272 if ( ! is_string( $icon ) || '' === $icon ) {
273 return $fallback;
274 }
275
276 $icon = trim( $icon );
277
278 if ( 'none' === $icon || 'div' === $icon ) {
279 return $fallback;
280 }
281
282 if ( 0 === strpos( $icon, 'dashicons-' ) ) {
283 // Allow only the safe subset of characters a Dashicons class can
284 // contain — prevents class-attribute break-out via spaces or
285 // quotes if a plugin registers a malicious "dashicons-…" value.
286 return preg_replace( '/[^a-z0-9_-]/', '', $icon );
287 }
288
289 // http/https URL — the icon is a hosted image.
290 if ( 0 === stripos( $icon, 'http://' ) || 0 === stripos( $icon, 'https://' ) ) {
291 $clean = esc_url_raw( $icon, array( 'http', 'https' ) );
292 return $clean ? $clean : $fallback;
293 }
294
295 // `data:image/svg+xml` — the canonical inline-icon shape WordPress
296 // plugins use for their admin-menu icon (`$menu[$i][6]`). Two valid
297 // payload encodings: base64 (`;base64,<base64>`) and URL-encoded
298 // (`,<percent-encoded>`). Reject everything outside the SVG MIME so
299 // `data:text/html` and `data:application/javascript` still bounce.
300 //
301 // Strict whole-string regex — no embedded whitespace, no smuggled
302 // quotes, no second `data:` prefix. Case-insensitive on the scheme
303 // alone since `Data:` and `DATA:` are syntactically valid but the
304 // payload portion stays case-sensitive (base64 alphabet is).
305 if ( 0 === stripos( $icon, 'data:image/svg+xml' ) ) {
306 if (
307 preg_match( '#^data:image/svg\+xml;base64,[A-Za-z0-9+/=]+$#i', $icon )
308 || preg_match( '#^data:image/svg\+xml,[A-Za-z0-9._~!$&\'()*+,;=:@/?%-]+$#i', $icon )
309 ) {
310 return $icon;
311 }
312 // Malformed SVG data URI — fall through to fallback rather than
313 // pass a half-validated string through to the renderer.
314 }
315
316 return $fallback;
317 }
318
319 /**
320 * Decides whether a given admin page should support multiple open windows.
321 *
322 * List-style screens (Posts, Pages, custom post types, Media, Users,
323 * Comments, taxonomy terms) often benefit from being open more than once:
324 * a writer may want to read one post while drafting another, compare two
325 * users side-by-side, pick media from one window and drop it into a draft
326 * in another. Singleton-ish screens (Dashboard, Settings, Tools, Profile)
327 * have a single logical state — opening two makes no sense.
328 *
329 * The default rule matches the base filename of the menu slug against a
330 * known list. Plugin authors can override via the
331 * `openstation_dock_item_multi` filter to mark any custom page as multi
332 * (or force a stock list page into singleton mode).
333 *
334 * @param string $menu_slug The raw menu slug (e.g. `edit.php`, `upload.php`,
335 * or `my-plugin-page`). Query strings are preserved
336 * so `edit.php?post_type=page` resolves correctly.
337 * @return bool True if this page supports multiple simultaneous windows.
338 */
339 function openstation_dock_item_is_multi( $menu_slug ) {
340 // Multi-capable admin files. Match by the base file regardless of
341 // any query string (post_type, taxonomy, page, paged, etc.) so every
342 // CPT and every taxonomy inherits the same rule as their parent.
343 $multi_files = array(
344 'edit.php',
345 'edit-tags.php',
346 'upload.php',
347 'users.php',
348 'edit-comments.php',
349 );
350
351 $base = strtok( (string) $menu_slug, '?' );
352 $multi = in_array( $base, $multi_files, true );
353
354 /**
355 * Filters whether a dock item supports multiple open windows.
356 *
357 * Return true to let the user open more than one window of this page.
358 * A "+" affordance appears on the dock icon and a "Open another" action
359 * becomes available in the window's title-bar menu. Singletons (false)
360 * always focus the existing window when re-opened.
361 *
362 * @param bool $multi Whether this page is multi-capable.
363 * @param string $menu_slug The menu slug (e.g. `edit.php?post_type=page`).
364 */
365 return (bool) apply_filters( 'openstation_dock_item_multi', $multi, $menu_slug );
366 }
367
368 /**
369 * Returns true when `$menu_slug` maps to a first-party WordPress
370 * Core admin menu item (Dashboard, Posts, Pages, Media, Settings,
371 * etc.), false otherwise. The caller uses the answer as an ordering
372 * hint — core items are placed ahead of plugin items in the
373 * unified dock rail.
374 *
375 * The rule:
376 *
377 * 1. Any known core admin filename (index.php, edit.php, upload.php,
378 * themes.php, plugins.php, users.php, tools.php, options-*.php,
379 * edit-comments.php, etc.) is Core.
380 * 2. Any Custom Post Type route (`edit.php?post_type=…`) is Core —
381 * CPTs are content-oriented even when a plugin registers them,
382 * so they belong next to Posts / Pages in the dock.
383 * 3. Every `admin.php?page=*` route is Plugin — that's WP's
384 * universal "a plugin registered its own top-level admin route"
385 * signal.
386 * 4. Anything else is treated as Plugin (safer default — plugins
387 * with custom top-level files can still opt in via the filter
388 * below).
389 *
390 * Plugins + site admins can override any answer via
391 * `openstation_dock_placement`:
392 *
393 * ```php
394 * // Keep Jetpack on the left dock:
395 * add_filter( 'openstation_dock_placement', function ( $placement, $slug ) {
396 * return 'jetpack' === $slug ? 'dock' : $placement;
397 * }, 10, 2 );
398 * ```
399 *
400 * @param string $menu_slug Menu item slug (e.g. `edit.php`, `edit.php?post_type=foo`, `woocommerce`).
401 * @return bool True when the slug is a core admin page.
402 */
403 function openstation_is_core_menu_slug( $menu_slug ) {
404 $slug = (string) $menu_slug;
405 $base = strtok( $slug, '?' );
406
407 // Known top-level core admin files. Stable across WP versions —
408 // additions happen maybe once a release, removals almost never.
409 $core_files = array(
410 'index.php', // Dashboard
411 'edit.php', // Posts (+ CPTs via ?post_type=)
412 'edit-comments.php', // Comments
413 'upload.php', // Media
414 'edit-tags.php', // Taxonomies
415 'term.php', // Single-term edit
416 'post-new.php', // New post form
417 'post.php', // Edit-post form
418 'themes.php', // Appearance
419 'nav-menus.php', // Menus (Appearance > Menus)
420 'widgets.php', // Widgets (Appearance > Widgets)
421 'customize.php', // Customizer
422 'plugins.php', // Plugins
423 'plugin-install.php', // Plugins > Add New
424 'plugin-editor.php', // Plugins > Editor
425 'users.php', // Users
426 'user-new.php', // Users > Add New
427 'profile.php', // Profile
428 'user-edit.php', // Edit another user
429 'tools.php', // Tools
430 'import.php', // Tools > Import
431 'export.php', // Tools > Export
432 'site-health.php', // Tools > Site Health
433 'export-personal-data.php',
434 'erase-personal-data.php',
435 'options-general.php', // Settings
436 'options-writing.php', // Settings > Writing
437 'options-reading.php', // Settings > Reading
438 'options-discussion.php', // Settings > Discussion
439 'options-media.php', // Settings > Media
440 'options-permalink.php', // Settings > Permalinks
441 'options-privacy.php', // Settings > Privacy
442 'link-manager.php', // Link manager (legacy)
443 'update-core.php', // Dashboard > Updates
444 );
445
446 return in_array( $base, $core_files, true );
447 }
448
449 /**
450 * Resolve the plugin file (e.g. `woocommerce/woocommerce.php`) that owns
451 * a given top-level admin menu slug, by reflecting on the callbacks
452 * registered for the menu's page hook.
453 *
454 * Returns the plugin's main file path (relative to `WP_PLUGIN_DIR`) when
455 * the menu was registered by a regular plugin, `null` otherwise. Core
456 * menus, mu-plugins, drop-ins, theme-registered menus, and OpenStation
457 * itself all return `null` — none of these are deactivatable through the
458 * `wp/v2/plugins` REST route, so the dock right-click menu should not
459 * offer a deactivate action for them.
460 *
461 * Resolution algorithm:
462 *
463 * 1. Skip core menu slugs outright — `plugins.php`, `edit.php?post_type=…`,
464 * etc. are never owned by a deactivatable plugin.
465 * 2. Compute the page hookname via `get_plugin_page_hookname()` and read
466 * `$wp_filter[ $hookname ]->callbacks`. This is the action list WP
467 * walks to render the menu's body — the plugin's own render callback
468 * lives here.
469 * 3. Reflect each callback to find its declaring file. Match the file
470 * path against `WP_PLUGIN_DIR/<folder>/…` and use `<folder>` to look
471 * up an entry in `get_plugins()`. Return the matching `<folder>/<file>.php`.
472 * 4. Exclude OpenStation itself — deactivating from inside the shell
473 * is handled by the plugins-window's self-deactivate path.
474 *
475 * @param string $menu_slug The menu slug from `$menu[$i][2]` (e.g. `woocommerce`,
476 * `admin.php?page=jetpack`, `edit.php?post_type=foo`).
477 * @return string|null Plugin file path relative to `WP_PLUGIN_DIR`, or null
478 * when the slug isn't owned by a deactivatable plugin.
479 */
480 function openstation_resolve_menu_plugin_file( $menu_slug ) {
481 $slug = (string) $menu_slug;
482
483 // `get_plugin_page_hookname` + `get_plugins` come from
484 // `wp-admin/includes/plugin.php`, which Core loads itself on
485 // every admin request. The resolver only runs in admin context
486 // (called during `admin_enqueue_scripts` and the `_admin_menu`
487 // tracker), so the symbols are always available. Bail rather
488 // than `require_once` something that's Core's job to load.
489 if ( ! function_exists( 'get_plugin_page_hookname' ) || ! function_exists( 'get_plugins' ) ) {
490 return null;
491 }
492
493 $self_basename = defined( 'OPENSTATION_FILE' ) ? plugin_basename( OPENSTATION_FILE ) : '';
494
495 // Strategy 1 — registration-time attribution. The admin_menu hook
496 // wrapper (see `openstation_install_menu_attribution_tracker`) snapshots
497 // `$menu`/`$submenu` around every admin_menu callback and records
498 // "this plugin file added this slug". This is the authoritative
499 // source — it captures menus whose page hook isn't predictable from
500 // the slug (e.g. WC's `wc-admin&path=/marketing`) and handles
501 // callbacks that simply forward to a shared renderer (which
502 // reflection would mis-attribute).
503 $map = openstation_menu_attribution_map();
504 if ( isset( $map[ $slug ] ) ) {
505 $plugin_file = $map[ $slug ];
506 if ( $self_basename && $plugin_file === $self_basename ) {
507 return null;
508 }
509 return $plugin_file;
510 }
511
512 // Strategy 2 — CPT / taxonomy registration tracker. Core's `edit.php`
513 // / `edit-tags.php` handle the render, so the page hook would never
514 // point at the registering plugin. We caught the plugin at
515 // `register_post_type()` / `register_taxonomy()` time via
516 // `debug_backtrace()`.
517 $tracked = openstation_lookup_taxonomy_or_post_type_plugin_file( $slug );
518 if ( null !== $tracked ) {
519 if ( $self_basename && $tracked === $self_basename ) {
520 return null;
521 }
522 return $tracked;
523 }
524
525 $base = strtok( $slug, '?' );
526
527 // Cheap reject: literal core PHP files with no `?page=` parameter
528 // (the universal "a plugin registered an admin route" signal). We
529 // can't reuse `openstation_is_core_menu_slug()` here — that
530 // classifier strtok's the query string and treats `admin.php?page=foo`
531 // as core, which would hide every plugin-registered top-level tile.
532 if ( openstation_is_pure_core_file( $base ) && false === strpos( $slug, '?page=' ) ) {
533 return null;
534 }
535
536 // Strategy 3 — page-hook reflection fallback. The earlier strategies
537 // can miss when a plugin is loaded after admin_menu has fired (rare),
538 // or when the menu was injected by a non-admin_menu pathway. Reflect
539 // on `$wp_filter[$hookname]` to find the callback's declaring file
540 // and map it back to an active plugin.
541 global $wp_filter;
542 $hookname = get_plugin_page_hookname( $slug, '' );
543 if ( empty( $hookname ) || empty( $wp_filter[ $hookname ] ) ) {
544 return null;
545 }
546
547 $hook = $wp_filter[ $hookname ];
548 foreach ( $hook->callbacks as $cbs ) {
549 foreach ( $cbs as $cb ) {
550 $plugin_file = openstation_plugin_file_for_callback( $cb['function'] ?? null );
551 if ( ! $plugin_file ) {
552 continue;
553 }
554 if ( $self_basename && $plugin_file === $self_basename ) {
555 return null;
556 }
557 return $plugin_file;
558 }
559 }
560
561 return null;
562 }
563
564 /**
565 * Look up the human-readable display name for a plugin file. Returns
566 * the plugin folder name as a last-resort fallback if `get_plugins()`
567 * has no entry (extremely rare — would mean the plugin file isn't
568 * installed but somehow registered a menu).
569 *
570 * @param string $plugin_file Plugin file relative to `WP_PLUGIN_DIR`.
571 * @return string Display name.
572 */
573 function openstation_plugin_display_name( $plugin_file ) {
574 if ( ! function_exists( 'get_plugins' ) ) {
575 $dir = strtok( $plugin_file, '/' );
576 return $dir ? $dir : $plugin_file;
577 }
578 $installed = get_plugins();
579 if ( isset( $installed[ $plugin_file ]['Name'] ) && '' !== $installed[ $plugin_file ]['Name'] ) {
580 return (string) $installed[ $plugin_file ]['Name'];
581 }
582 $folder = strtok( $plugin_file, '/' );
583 return $folder ? $folder : $plugin_file;
584 }
585
586 /**
587 * Map an arbitrary filesystem path inside `WP_PLUGIN_DIR` to the
588 * corresponding plugin file in `get_plugins()`. Returns null when the
589 * path isn't under the plugins directory, or doesn't match any active
590 * plugin folder.
591 *
592 * @param string $file Absolute filesystem path.
593 * @return string|null Plugin file (`<folder>/<file>.php`) or null.
594 */
595 function openstation_plugin_file_for_path( $file ) {
596 if ( ! is_string( $file ) || '' === $file ) {
597 return null;
598 }
599 $plugins_dir = wp_normalize_path( WP_PLUGIN_DIR );
600 $norm = wp_normalize_path( $file );
601 if ( 0 !== strpos( $norm, $plugins_dir . '/' ) ) {
602 return null;
603 }
604 if ( ! function_exists( 'get_plugins' ) ) {
605 return null;
606 }
607 $installed = get_plugins();
608
609 $rel = ltrim( substr( $norm, strlen( $plugins_dir ) ), '/' );
610 $folder = ( false !== strpos( $rel, '/' ) ) ? strtok( $rel, '/' ) : '';
611
612 foreach ( $installed as $plugin_file => $_data ) {
613 if ( '' !== $folder && 0 === strpos( $plugin_file, $folder . '/' ) ) {
614 return $plugin_file;
615 }
616 if ( '' === $folder && $plugin_file === $rel ) {
617 return $plugin_file;
618 }
619 }
620 return null;
621 }
622
623 /**
624 * Convenience wrapper: reflect on a callback to find its declaring
625 * file, then map that file to an active plugin via
626 * {@see openstation_plugin_file_for_path()}.
627 *
628 * @param mixed $callback A WP-style callback.
629 * @return string|null Plugin file or null.
630 */
631 function openstation_plugin_file_for_callback( $callback ) {
632 $file = openstation_callback_source_file( $callback );
633 return $file ? openstation_plugin_file_for_path( $file ) : null;
634 }
635
636 /**
637 * Lazy accessor + lazy initializer for the registration-time menu
638 * attribution map: `slug → plugin_file`. The map is populated by the
639 * wrapped admin_menu callbacks installed by
640 * {@see openstation_install_menu_attribution_tracker()}.
641 *
642 * @return array<string,string>
643 */
644 function &openstation_menu_attribution_map() {
645 static $map = null;
646 if ( null === $map ) {
647 $map = array();
648 }
649 return $map;
650 }
651
652 /**
653 * Install admin_menu callback wrappers that record which plugin file
654 * registered each `$menu` / `$submenu` slug.
655 *
656 * Approach:
657 *
658 * 1. Hooked on `_admin_menu` priority `-PHP_INT_MAX`, just before
659 * `admin_menu` fires.
660 * 2. Walk `$wp_filter['admin_menu']->callbacks`. For each callback,
661 * reflect on the function to find its declaring file → plugin
662 * file. If the callback doesn't live in `WP_PLUGIN_DIR`, leave it
663 * alone (Core's own callbacks).
664 * 3. Replace the callback in-place with a closure that snapshots
665 * `$menu` and `$submenu` keys, invokes the original, then diffs
666 * the globals. Every new top-level slug and every new submenu
667 * entry gets attributed to that plugin file.
668 *
669 * This is the source of truth for plugin → menu ownership because it
670 * captures menus regardless of slug shape, hook name predictability,
671 * or whether the plugin shares a render callback. Reflection on the
672 * page hook (in `openstation_resolve_menu_plugin_file`) is now a
673 * fallback for the rare cases where the tracker wasn't able to install
674 * in time.
675 *
676 * Idempotent — runs at most once per request via a static `$installed`
677 * flag.
678 *
679 * @return void
680 */
681 function openstation_install_menu_attribution_tracker() {
682 static $installed = false;
683 if ( $installed ) {
684 return;
685 }
686 $installed = true;
687
688 global $wp_filter;
689 if ( empty( $wp_filter['admin_menu'] ) ) {
690 return;
691 }
692 $hook = $wp_filter['admin_menu'];
693
694 foreach ( $hook->callbacks as $priority => $cbs ) {
695 foreach ( $cbs as $id => $cb ) {
696 $orig = $cb['function'] ?? null;
697 $plugin_file = openstation_plugin_file_for_callback( $orig );
698 if ( ! $plugin_file || ! is_callable( $orig ) ) {
699 continue;
700 }
701 $accepted_args = (int) ( $cb['accepted_args'] ?? 1 );
702
703 $wrapper = static function () use ( $orig, $plugin_file ) {
704 global $menu, $submenu;
705
706 $before_top_slugs = array();
707 if ( is_array( $menu ) ) {
708 foreach ( $menu as $entry ) {
709 if ( isset( $entry[2] ) ) {
710 $before_top_slugs[ (string) $entry[2] ] = true;
711 }
712 }
713 }
714 $before_submenu_keys = is_array( $submenu ) ? array_keys( $submenu ) : array();
715 $before_submenu_sigs = array();
716 if ( is_array( $submenu ) ) {
717 foreach ( $submenu as $parent => $children ) {
718 $sigs = array();
719 foreach ( (array) $children as $child ) {
720 if ( isset( $child[2] ) ) {
721 $sigs[ (string) $child[2] ] = true;
722 }
723 }
724 $before_submenu_sigs[ $parent ] = $sigs;
725 }
726 }
727
728 $args = func_get_args();
729 $return = call_user_func_array( $orig, $args );
730
731 $map = &openstation_menu_attribution_map();
732
733 if ( is_array( $menu ) ) {
734 foreach ( $menu as $entry ) {
735 if ( ! isset( $entry[2] ) ) {
736 continue;
737 }
738 $slug = (string) $entry[2];
739 if ( ! isset( $before_top_slugs[ $slug ] ) && ! isset( $map[ $slug ] ) ) {
740 $map[ $slug ] = $plugin_file;
741 }
742 }
743 }
744
745 if ( is_array( $submenu ) ) {
746 foreach ( $submenu as $parent => $children ) {
747 $prev_sigs = $before_submenu_sigs[ $parent ] ?? array();
748 foreach ( (array) $children as $child ) {
749 if ( ! isset( $child[2] ) ) {
750 continue;
751 }
752 $slug = (string) $child[2];
753 if ( isset( $prev_sigs[ $slug ] ) ) {
754 continue;
755 }
756 if ( ! isset( $map[ $slug ] ) ) {
757 $map[ $slug ] = $plugin_file;
758 }
759 // Also attribute the parent if it isn't
760 // already attributed and Core doesn't own it.
761 // Lets a submenu-only plugin (registered
762 // under a Core parent like `tools.php`) be
763 // resolvable too.
764 }
765 if (
766 ! in_array( $parent, $before_submenu_keys, true )
767 && ! isset( $map[ $parent ] )
768 ) {
769 $map[ $parent ] = $plugin_file;
770 }
771 }
772 }
773
774 return $return;
775 };
776
777 // Preserve the `accepted_args` metadata so callbacks
778 // expecting parameters from `do_action_ref_array()` still
779 // receive them. The wrapper uses `func_get_args()` so it
780 // forwards everything.
781 $wp_filter['admin_menu']->callbacks[ $priority ][ $id ] = array(
782 'function' => $wrapper,
783 'accepted_args' => $accepted_args,
784 );
785 }
786 }
787 }
788
789 add_action( '_admin_menu', 'openstation_install_menu_attribution_tracker', -PHP_INT_MAX );
790 add_action( '_network_admin_menu', 'openstation_install_menu_attribution_tracker', -PHP_INT_MAX );
791 add_action( '_user_admin_menu', 'openstation_install_menu_attribution_tracker', -PHP_INT_MAX );
792
793 /**
794 * The subset of `openstation_is_core_menu_slug`'s "core files" that's
795 * actually owned by Core regardless of any query string — this is what
796 * we use inside the plugin-file resolver to reject Posts / Pages / etc.
797 * without rejecting `admin.php?page=…` (a universal plugin signal that
798 * the public is_core classifier also incorrectly treats as core for
799 * legacy reasons we don't want to disturb).
800 *
801 * The list intentionally drops `admin.php` so plugin-registered
802 * top-level pages can still be resolved.
803 *
804 * @param string $base Slug with query string already stripped.
805 * @return bool True when the base filename is a Core admin handler.
806 */
807 function openstation_is_pure_core_file( $base ) {
808 $core_files = array(
809 'index.php',
810 'edit-comments.php',
811 'upload.php',
812 'term.php',
813 'post-new.php',
814 'post.php',
815 'themes.php',
816 'nav-menus.php',
817 'widgets.php',
818 'customize.php',
819 'plugins.php',
820 'plugin-install.php',
821 'plugin-editor.php',
822 'users.php',
823 'user-new.php',
824 'profile.php',
825 'user-edit.php',
826 'tools.php',
827 'import.php',
828 'export.php',
829 'site-health.php',
830 'export-personal-data.php',
831 'erase-personal-data.php',
832 'options-general.php',
833 'options-writing.php',
834 'options-reading.php',
835 'options-discussion.php',
836 'options-media.php',
837 'options-permalink.php',
838 'options-privacy.php',
839 'link-manager.php',
840 'update-core.php',
841 );
842 return in_array( $base, $core_files, true );
843 }
844
845 /**
846 * Resolve a CPT / taxonomy URL slug (`edit.php?post_type=X` or
847 * `edit-tags.php?taxonomy=Y`) to the plugin file that registered the
848 * type. The mapping is built lazily on `init` by capturing the
849 * filename of whichever code called `register_post_type()` /
850 * `register_taxonomy()` for non-builtin types.
851 *
852 * Returns null when the slug isn't a CPT / taxonomy URL, when the
853 * registered type is builtin, or when the registrant lives outside
854 * `WP_PLUGIN_DIR` (theme-registered or mu-plugin).
855 *
856 * @param string $slug Menu slug.
857 * @return string|null Plugin file or null.
858 */
859 function openstation_lookup_taxonomy_or_post_type_plugin_file( $slug ) {
860 if ( false !== strpos( $slug, 'edit.php?' ) && false !== strpos( $slug, 'post_type=' ) ) {
861 $qs = wp_parse_url( 'http://x/' . ltrim( $slug, '/' ), PHP_URL_QUERY );
862 parse_str( (string) $qs, $args );
863 $pt = isset( $args['post_type'] ) ? (string) $args['post_type'] : '';
864 if ( '' === $pt ) {
865 return null;
866 }
867 $file = openstation_type_registrant_file( $pt, 'post_type' );
868 return null === $file ? null : openstation_plugin_file_for_path( $file );
869 }
870 if ( false !== strpos( $slug, 'edit-tags.php?' ) && false !== strpos( $slug, 'taxonomy=' ) ) {
871 $qs = wp_parse_url( 'http://x/' . ltrim( $slug, '/' ), PHP_URL_QUERY );
872 parse_str( (string) $qs, $args );
873 $tx = isset( $args['taxonomy'] ) ? (string) $args['taxonomy'] : '';
874 if ( '' === $tx ) {
875 return null;
876 }
877 $file = openstation_type_registrant_file( $tx, 'taxonomy' );
878 return null === $file ? null : openstation_plugin_file_for_path( $file );
879 }
880 return null;
881 }
882
883 /**
884 * Lazy accessor for the CPT/taxonomy → registering-file map. The map is
885 * populated by `openstation_record_type_registrant()` (hooked on
886 * `registered_post_type` / `registered_taxonomy`, which fire during
887 * `init`), so by the time the dock payload is built — on
888 * `admin_enqueue_scripts`, well after `init` — every non-builtin type
889 * registered from an extension has an entry. Stored in a static so
890 * repeated lookups during a single request don't trigger the populator
891 * twice.
892 *
893 * Values are **absolute filesystem paths**, not plugin files. Core does
894 * not load `wp-admin/includes/plugin.php` (where `get_plugins()` lives)
895 * until `wp-admin/admin.php` runs it *after* `wp-load.php` has already
896 * fired `init` — so a plugin file cannot be resolved at record time.
897 * Callers resolve the path lazily instead:
898 * `openstation_lookup_taxonomy_or_post_type_plugin_file()` for the
899 * dock's plugin attribution, and the My WordPress group resolver for
900 * the plugin / mu-plugin / theme split.
901 *
902 * @return array{post_type: array<string,string>, taxonomy: array<string,string>}
903 */
904 function &openstation_get_typed_registrant_map() {
905 static $map = null;
906 if ( null === $map ) {
907 $map = array(
908 'post_type' => array(),
909 'taxonomy' => array(),
910 );
911 }
912 return $map;
913 }
914
915 /**
916 * Read the recorded registering file for a CPT or taxonomy.
917 *
918 * @param string $type Type name (CPT or taxonomy).
919 * @param string $kind Either `'post_type'` or `'taxonomy'`.
920 * @return string|null Absolute normalized path, or null when unrecorded.
921 */
922 function openstation_type_registrant_file( $type, $kind ) {
923 $map = openstation_get_typed_registrant_map();
924 return $map[ $kind ][ $type ] ?? null;
925 }
926
927 /**
928 * Whether this request will ever read the CPT / taxonomy attribution
929 * map, and is therefore worth paying a `debug_backtrace()` per
930 * non-builtin type registration to build it.
931 *
932 * Only admin-side surfaces consume it: the dock payload (built on
933 * `admin_enqueue_scripts`) and the site window's section list (built
934 * on `init`, admin only). A front-end page view registers exactly the
935 * same types — WooCommerce alone brings several — and would pay the
936 * whole cost for a map nothing reads.
937 *
938 * The predecessor of this function got the same effect by accident:
939 * it bailed when `get_plugins()` was undefined, which is every
940 * front-end request. That guard went away when the resolution moved to
941 * lazy path recording, so the gate is now explicit.
942 *
943 * @return bool
944 */
945 function openstation_should_track_type_registrants() {
946 $track = is_admin();
947
948 /**
949 * Filter whether to record which extension registered each CPT and
950 * taxonomy this request.
951 *
952 * The map drives the dock's "Deactivate <plugin>" action and the
953 * site window's plugin folders. Return true on a front-end request
954 * only if something there reads it — building it costs one bounded
955 * backtrace per non-builtin type registration.
956 *
957 * **Status: Experimental**
958 *
959 * @param bool $track Default: admin requests only.
960 */
961 return (bool) apply_filters( 'openstation_track_type_registrants', $track );
962 }
963
964 /**
965 * Record the registering file for a CPT or taxonomy. Hooked at
966 * `registered_post_type` / `registered_taxonomy` priority 9999 so we
967 * fire after every other listener has run (lets a plugin re-register
968 * its own type on top of someone else's — last writer wins, which
969 * matches WP's runtime semantics).
970 *
971 * Resolution is via `debug_backtrace()`: walk frames until we hit one
972 * whose `file` lives inside an extension directory (plugins, mu-plugins,
973 * or a theme root). Cheap — the backtrace is bounded and runs once per
974 * type registration, all during `init`.
975 *
976 * @param string $type_or_post_type Type name (CPT or taxonomy).
977 * @param string $kind Either `'post_type'` or `'taxonomy'`.
978 * @return void
979 */
980 function openstation_record_type_registrant( $type_or_post_type, $kind ) {
981 if ( '' === (string) $type_or_post_type ) {
982 return;
983 }
984 if ( ! openstation_should_track_type_registrants() ) {
985 return;
986 }
987 // Skip Core builtin types — they're registered from Core itself
988 // (Posts, Pages, Categories, …) and the backtrace would never land
989 // inside WP_PLUGIN_DIR anyway. Cheap pre-filter.
990 if ( 'post_type' === $kind ) {
991 $obj = get_post_type_object( $type_or_post_type );
992 if ( $obj && ! empty( $obj->_builtin ) ) {
993 return;
994 }
995 } elseif ( 'taxonomy' === $kind ) {
996 $obj = get_taxonomy( $type_or_post_type );
997 if ( $obj && ! empty( $obj->_builtin ) ) {
998 return;
999 }
1000 }
1001
1002 $file = openstation_registrant_file_from_backtrace();
1003 if ( null === $file ) {
1004 return;
1005 }
1006 $map = &openstation_get_typed_registrant_map();
1007 $map[ $kind ][ $type_or_post_type ] = $file;
1008 }
1009
1010 /**
1011 * The extension directories a registration can legitimately come from,
1012 * normalized and trailing-slashed. Anything else (Core itself, a
1013 * drop-in, `wp-config.php`) is not attributable to an extension.
1014 *
1015 * @return string[] Normalized directory prefixes.
1016 */
1017 function openstation_extension_dirs() {
1018 static $dirs = null;
1019 if ( null !== $dirs ) {
1020 return $dirs;
1021 }
1022 $dirs = array();
1023 if ( defined( 'WP_PLUGIN_DIR' ) ) {
1024 $dirs[] = wp_normalize_path( WP_PLUGIN_DIR ) . '/';
1025 }
1026 if ( defined( 'WPMU_PLUGIN_DIR' ) ) {
1027 $dirs[] = wp_normalize_path( WPMU_PLUGIN_DIR ) . '/';
1028 }
1029 foreach ( (array) get_theme_roots() as $theme_root ) {
1030 // `get_theme_roots()` returns roots relative to `wp-content`
1031 // when there's only one; `get_theme_root()` normalizes that.
1032 $dirs[] = wp_normalize_path( get_theme_root( (string) $theme_root ) ) . '/';
1033 }
1034 $dirs = array_values( array_unique( array_filter( $dirs ) ) );
1035 return $dirs;
1036 }
1037
1038 /**
1039 * Walk the current PHP backtrace and return the closest frame that
1040 * lives inside an extension directory (plugin, mu-plugin, or theme).
1041 *
1042 * Frames belonging to OpenStation itself are skipped: this function is
1043 * called from `payload.php`, which is under `WP_PLUGIN_DIR`, so the two
1044 * innermost frames would otherwise match and attribute every registered
1045 * type to us.
1046 *
1047 * Used by the CPT / taxonomy registration tracker to attribute
1048 * `register_post_type()` / `register_taxonomy()` calls without forcing
1049 * Core to load `wp-admin/includes/plugin.php` earlier than it would —
1050 * `get_plugins()` does not exist yet at `init`.
1051 *
1052 * @return string|null Normalized absolute path, or null.
1053 */
1054 function openstation_registrant_file_from_backtrace() {
1055 $self_dir = defined( 'OPENSTATION_DIR' ) ? wp_normalize_path( OPENSTATION_DIR ) : '';
1056 $self_dir = $self_dir ? trailingslashit( $self_dir ) : '';
1057 $dirs = openstation_extension_dirs();
1058 if ( empty( $dirs ) ) {
1059 return null;
1060 }
1061
1062 $bt = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 20 );
1063 foreach ( $bt as $frame ) {
1064 if ( empty( $frame['file'] ) ) {
1065 continue;
1066 }
1067 $norm = wp_normalize_path( (string) $frame['file'] );
1068 if ( '' !== $self_dir && 0 === strpos( $norm, $self_dir ) ) {
1069 continue;
1070 }
1071 foreach ( $dirs as $dir ) {
1072 if ( 0 === strpos( $norm, $dir ) ) {
1073 return $norm;
1074 }
1075 }
1076 }
1077 return null;
1078 }
1079
1080 add_action(
1081 'registered_post_type',
1082 static function ( $post_type ) {
1083 openstation_record_type_registrant( $post_type, 'post_type' );
1084 },
1085 9999,
1086 1
1087 );
1088
1089 add_action(
1090 'registered_taxonomy',
1091 static function ( $taxonomy ) {
1092 openstation_record_type_registrant( $taxonomy, 'taxonomy' );
1093 },
1094 9999,
1095 1
1096 );
1097
1098 /**
1099 * Resolve the declaring file of a hook callback. Handles closures,
1100 * `[ $object, 'method' ]`, `[ 'Class', 'method' ]`, plain function names,
1101 * and `'Class::method'` strings. Returns null when reflection fails or
1102 * the callback shape isn't reflectable (rare — e.g. an invocable object
1103 * whose `__invoke` lives in PHP core).
1104 *
1105 * @param mixed $callback A callback as stored in `WP_Hook::$callbacks[$prio][$id]['function']`.
1106 * @return string|null Absolute filesystem path of the declaring file, or null.
1107 */
1108 function openstation_callback_source_file( $callback ) {
1109 if ( empty( $callback ) ) {
1110 return null;
1111 }
1112 try {
1113 if ( is_string( $callback ) && false !== strpos( $callback, '::' ) ) {
1114 list( $class, $method ) = explode( '::', $callback, 2 );
1115 $ref = new ReflectionMethod( $class, $method );
1116 } elseif ( is_array( $callback ) && isset( $callback[0], $callback[1] ) ) {
1117 $ref = new ReflectionMethod( $callback[0], (string) $callback[1] );
1118 } elseif ( is_object( $callback ) && ! ( $callback instanceof Closure ) && method_exists( $callback, '__invoke' ) ) {
1119 $ref = new ReflectionMethod( $callback, '__invoke' );
1120 } elseif ( is_callable( $callback ) ) {
1121 $ref = new ReflectionFunction( $callback );
1122 } else {
1123 return null;
1124 }
1125 $file = $ref->getFileName();
1126 return $file ? $file : null;
1127 } catch ( ReflectionException $e ) {
1128 return null;
1129 }
1130 }
1131
1132 /**
1133 * Resolve whether a given menu slug is rendered in the dock.
1134 * Returns one of two values:
1135 *
1136 * - `'dock'` — render this item on the unified dock rail.
1137 * - `'hidden'` — don't render this item anywhere in the desktop
1138 * shell. The underlying admin menu entry still
1139 * exists server-side; this only suppresses the
1140 * desktop-shell tile.
1141 *
1142 * Default is `'dock'` for every menu item. Plugins + site admins can
1143 * hide individual items via the `openstation_dock_placement` filter.
1144 *
1145 * @param string $menu_slug The menu slug (e.g. `edit.php`, `woocommerce`).
1146 * @return string `'dock'` or `'hidden'`.
1147 */
1148 function openstation_dock_placement( $menu_slug ) {
1149 /**
1150 * Filter whether a specific menu item is shown in the dock.
1151 *
1152 * Return `'dock'` to render the item on the dock (default) or
1153 * `'hidden'` to suppress it entirely. Any other value coerces to
1154 * `'dock'` — a defensive guard so a misbehaving filter can't
1155 * corrupt the dock with `null` / `false` / arbitrary strings.
1156 *
1157 * @param string $placement Default — always `'dock'`.
1158 * @param string $menu_slug The menu slug triggering the lookup.
1159 */
1160 $filtered = apply_filters( 'openstation_dock_placement', 'dock', $menu_slug );
1161 return 'hidden' === $filtered ? 'hidden' : 'dock';
1162 }
1163
1164 /**
1165 * Assemble the menu payload consumed by the shell.
1166 *
1167 * Runs the full dock-builder and returns a single `dockItems` array —
1168 * core WordPress menus first (Dashboard, Posts, Media, …), then
1169 * plugin-contributed top-level menus. Items whose `placement` is
1170 * `'hidden'` are dropped entirely.
1171 *
1172 * Extracted out of `includes/render.php` so both the initial PHP
1173 * localize AND the chromeless bridge's live-refresh emit (including
1174 * the hidden-iframe probe spawned by `wp.os.refreshMenu()`)
1175 * read from a single source of truth — any drift would desync the
1176 * live refresh.
1177 *
1178 * @return array{dockItems: array[]} Menu payload.
1179 */
1180 function openstation_build_menu_payload() {
1181 $all = openstation_build_dock_items();
1182
1183 // Drop hidden items; preserve the default "core first, plugins
1184 // after" ordering by partitioning on the core classifier.
1185 $visible = array_values(
1186 array_filter(
1187 $all,
1188 static function ( $item ) {
1189 return 'hidden' !== ( $item['placement'] ?? 'dock' );
1190 }
1191 )
1192 );
1193
1194 // Partition on the per-item `isCore` flag set in
1195 // openstation_build_dock_items — that classifier ran against the
1196 // raw menu slug ($item[2]), which is what
1197 // openstation_is_core_menu_slug actually compares. The outer 'id'
1198 // field is a sanitized CSS id (e.g. `toplevel_page_jetpack`) and
1199 // would never match.
1200 $core = array();
1201 $plugin = array();
1202 foreach ( $visible as $item ) {
1203 if ( ! empty( $item['isCore'] ) ) {
1204 $core[] = $item;
1205 } else {
1206 $plugin[] = $item;
1207 }
1208 }
1209
1210 $dock = array_merge( $core, $plugin );
1211
1212 $payload = array(
1213 'dockItems' => $dock,
1214 'nativeWindows' => openstation_build_native_windows_payload(),
1215 );
1216
1217 // Optional per-surface payload builders — each module ships a
1218 // zero-arg `openstation_build_*_payload()`; modules that aren't
1219 // loaded this request contribute an empty array.
1220 $builders = array(
1221 'serverWidgets' => 'openstation_build_desktop_widgets_payload',
1222 'serverWallpapers' => 'openstation_build_desktop_wallpapers_payload',
1223 'serverCommandScripts' => 'openstation_build_desktop_command_scripts_payload',
1224 'serverCommands' => 'openstation_build_desktop_commands_payload',
1225 'serverSettingsTabScripts' => 'openstation_build_desktop_settings_tab_scripts_payload',
1226 'serverSettingsTabs' => 'openstation_build_desktop_settings_tabs_payload',
1227 'serverDockRailRendererScripts' => 'openstation_build_dock_rail_renderer_scripts_payload',
1228 'serverTitleBarButtonScripts' => 'openstation_build_desktop_titlebar_button_scripts_payload',
1229 'serverWindowActionScripts' => 'openstation_build_desktop_window_action_scripts_payload',
1230 'serverUnfocusEffectScripts' => 'openstation_build_desktop_unfocus_effect_scripts_payload',
1231 'serverWindowLinkRendererScripts' => 'openstation_build_window_link_renderer_scripts_payload',
1232 'serverWindowThemeScripts' => 'openstation_build_window_theme_scripts_payload',
1233 'serverWindowThemes' => 'openstation_build_window_themes_payload',
1234 'serverWindowControlScripts' => 'openstation_build_window_control_scripts_payload',
1235 'serverWindowControls' => 'openstation_build_window_controls_payload',
1236 'serverWindowSlotScripts' => 'openstation_build_window_slot_scripts_payload',
1237 'serverWindowSlots' => 'openstation_build_window_slots_payload',
1238 'serverWindowChromeScripts' => 'openstation_build_window_chrome_scripts_payload',
1239 'serverWindowChromes' => 'openstation_build_window_chromes_payload',
1240 'serverWindowNotices' => 'openstation_build_window_notices_payload',
1241 'serverGames' => 'openstation_build_desktop_games_payload',
1242 'serverDesktopThemes' => 'openstation_build_desktop_themes_payload',
1243 'desktopIcons' => 'openstation_build_desktop_icons_payload',
1244 );
1245
1246 foreach ( $builders as $key => $builder ) {
1247 $payload[ $key ] = function_exists( $builder ) ? $builder() : array();
1248 }
1249
1250 // Aggregate update counts for the admin bar's "updates" notifier
1251 // (the circle-arrows badge Core renders top-left). The node is
1252 // static server HTML on the shell page, so after an in-window
1253 // update run the shell needs fresh numbers to repaint it — GH#296.
1254 // `wp_get_update_data()` is capability-aware (plugins / themes /
1255 // core each gated), so the count matches what this user can act
1256 // on. Strings are prebuilt here so the client repaint stays
1257 // locale-correct without shipping translations to JS.
1258 if ( function_exists( 'wp_get_update_data' ) ) {
1259 $update_data = wp_get_update_data();
1260 $update_total = isset( $update_data['counts']['total'] ) ? (int) $update_data['counts']['total'] : 0;
1261
1262 $payload['updateCounts'] = array(
1263 'total' => $update_total,
1264 'formatted' => number_format_i18n( $update_total ),
1265 'text' => sprintf(
1266 /* translators: %s: number of pending updates. */
1267 _n( '%s update available', '%s updates available', $update_total, 'desktop-mode' ),
1268 number_format_i18n( $update_total )
1269 ),
1270 'url' => network_admin_url( 'update-core.php' ),
1271 );
1272 }
1273
1274 // A cheap structural fingerprint of the admin menu the shell uses to
1275 // decide whether a live refresh is warranted. Shipped in every full
1276 // payload so the shell can seed / update its last-known signature
1277 // without recomputing it client-side (which would risk drift from
1278 // the server's capability-gated view). See
1279 // openstation_menu_signature().
1280 $payload['menuSig'] = openstation_menu_signature();
1281
1282 return $payload;
1283 }
1284
1285 /**
1286 * Cheap structural fingerprint of the current admin menu.
1287 *
1288 * The chromeless bridge emits the *full* menu payload only from the
1289 * handful of pages whose completion commonly mutates the admin menu
1290 * (activation / install / theme switch). That leaves a gap: a custom
1291 * post type registered through a settings-based tool (CPT UI, Pods,
1292 * ACF, …) saves on its own `admin.php?page=…` / `options.php` screen,
1293 * none of which is in that list, so the new top-level menu never
1294 * reaches the live dock until a full browser reload rebuilds the shell
1295 * (GH#325).
1296 *
1297 * Building the full payload on *every* chromeless page just to catch
1298 * that case would be wasteful — most navigations don't touch the menu.
1299 * Instead every chromeless page ships this lightweight signature; the
1300 * shell compares it against its last-known value and only spends a
1301 * `wp.os.refreshMenu()` probe when it actually changed.
1302 *
1303 * The hash covers the capability-passing top-level + submenu slugs and
1304 * their (badge-stripped) titles — i.e. exactly the add / remove /
1305 * rename events the dock cares about. Transient badge counts (update
1306 * notifications, moderation queues) are stripped so they don't churn
1307 * the signature; those have their own refresh path.
1308 *
1309 * @return string 32-char md5 fingerprint, or '' when the menu is
1310 * unavailable (non-admin context).
1311 */
1312 function openstation_menu_signature() {
1313 global $menu, $submenu;
1314
1315 if ( empty( $menu ) || ! is_array( $menu ) ) {
1316 return '';
1317 }
1318
1319 $clean_title = static function ( $raw ) {
1320 // Mirror openstation_build_dock_items(): drop badge spans first,
1321 // then any remaining markup, so update counts don't move the hash.
1322 $stripped = preg_replace( '/<span[^>]*>.*?<\/span>/s', '', (string) $raw );
1323 return trim( wp_strip_all_tags( (string) $stripped ) );
1324 };
1325
1326 $parts = array();
1327
1328 foreach ( $menu as $item ) {
1329 if ( empty( $item[2] ) ) {
1330 continue;
1331 }
1332 if ( ! empty( $item[4] ) && false !== strpos( $item[4], 'wp-menu-separator' ) ) {
1333 continue;
1334 }
1335 if ( ! empty( $item[1] ) && ! current_user_can( $item[1] ) ) {
1336 continue;
1337 }
1338
1339 $slug = (string) $item[2];
1340 $parts[] = $slug . '|' . $clean_title( $item[0] ?? '' );
1341
1342 if ( empty( $submenu[ $slug ] ) || ! is_array( $submenu[ $slug ] ) ) {
1343 continue;
1344 }
1345 foreach ( $submenu[ $slug ] as $sub_item ) {
1346 if ( ! empty( $sub_item[1] ) && ! current_user_can( $sub_item[1] ) ) {
1347 continue;
1348 }
1349 $parts[] = "\t" . ( isset( $sub_item[2] ) ? (string) $sub_item[2] : '' )
1350 . '|' . $clean_title( $sub_item[0] ?? '' );
1351 }
1352 }
1353
1354 return md5( implode( "\n", $parts ) );
1355 }
1356
1357 /**
1358 * Resolve a registered WP script handle into the full payload the
1359 * shell needs to lazy-load it without going through `wp_print_scripts()`.
1360 *
1361 * Returns:
1362 *
1363 * ```
1364 * array(
1365 * 'url' => 'https://…/script.js?ver=…',
1366 * 'before' => array( /* `wp_add_inline_script( $h, $code, 'before' )` strings *\/ ),
1367 * 'after' => array( /* `wp_add_inline_script( $h, $code, 'after' )` strings *\/ ),
1368 * 'l10n' => array( /* `wp_localize_script( $h, $name, $data )` precomputed `<script>var $name = …;</script>` strings *\/ ),
1369 * 'translations' => string, /* `wp_set_script_translations()` JED chunk *\/
1370 * )
1371 * ```
1372 *
1373 * **The `l10n` / `before` / `after` / `translations` fields exist
1374 * because the lazy-load path in the shell appends a raw
1375 * `<script src="…">` and never invokes `wp_print_scripts()` — so any
1376 * `wp_localize_script` / `wp_add_inline_script` / `wp_set_script_translations`
1377 * data attached to the handle would be silently dropped without this
1378 * harvest.** The shell injects each entry as inline `<script>` tags
1379 * around the lazy `<script src>` in the same order
1380 * `WP_Scripts::do_item()` would have used.
1381 *
1382 * Returns an empty payload (`array( 'url' => '' )`) when the handle
1383 * is unregistered or has no source — callers treat that as "no
1384 * script to load."
1385 *
1386 * Shared between `openstation_register_window()` and
1387 * `openstation_register_widget()` (and every other registration that
1388 * relies on lazy script loading in the shell) because all of them
1389 * need identical handle→payload plumbing to power mid-session dynamic
1390 * script loading without the `wp_print_scripts` lifecycle.
1391 *
1392 * @param string $handle WP script handle.
1393 * @return array{ url:string, before:string[], after:string[], l10n:string[], translations:string } Payload (empty `url` on miss).
1394 */
1395 function openstation_resolve_script_payload( $handle ) {
1396 $empty = array(
1397 'url' => '',
1398 'before' => array(),
1399 'after' => array(),
1400 'l10n' => array(),
1401 'translations' => '',
1402 );
1403
1404 $handle = (string) $handle;
1405 if ( '' === $handle ) {
1406 return $empty;
1407 }
1408 $wp_scripts = wp_scripts();
1409 if ( ! $wp_scripts || ! isset( $wp_scripts->registered[ $handle ] ) ) {
1410 return $empty;
1411 }
1412 $registered = $wp_scripts->registered[ $handle ];
1413 $src = is_string( $registered->src ) ? $registered->src : '';
1414 if ( '' === $src ) {
1415 return $empty;
1416 }
1417
1418 // Normalize relative paths + attach cache-bust ver.
1419 $resolved = $src;
1420 if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) {
1421 $resolved = site_url( $resolved );
1422 }
1423 if ( ! empty( $registered->ver ) ) {
1424 $resolved = add_query_arg( 'ver', $registered->ver, $resolved );
1425 }
1426
1427 // Harvest `extra` data the lazy-load path would otherwise drop.
1428 $before = array();
1429 $after = array();
1430 $l10n = array();
1431
1432 if ( isset( $registered->extra['before'] ) && is_array( $registered->extra['before'] ) ) {
1433 foreach ( $registered->extra['before'] as $code ) {
1434 $code = (string) $code;
1435 if ( '' !== $code ) {
1436 $before[] = $code;
1437 }
1438 }
1439 }
1440 if ( isset( $registered->extra['after'] ) && is_array( $registered->extra['after'] ) ) {
1441 foreach ( $registered->extra['after'] as $code ) {
1442 $code = (string) $code;
1443 if ( '' !== $code ) {
1444 $after[] = $code;
1445 }
1446 }
1447 }
1448 // `wp_localize_script` stores its JS at `extra['data']` as a single
1449 // concatenated string of `var x = …;` assignments. We capture it
1450 // verbatim — the shell will eval it as the body of an inline
1451 // `<script>` tag, mirroring what `WP_Scripts::print_extra_script()`
1452 // does at print time.
1453 if ( ! empty( $registered->extra['data'] ) && is_string( $registered->extra['data'] ) ) {
1454 $l10n[] = $registered->extra['data'];
1455 }
1456
1457 // Translations chunk — `wp_set_script_translations()` builds a
1458 // `wp.i18n.setLocaleData( JSON, 'domain' )` snippet that the print
1459 // pipeline emits before the script body. `print_translations(
1460 // $handle, false )` returns the snippet without echoing.
1461 $translations = '';
1462 if ( method_exists( $wp_scripts, 'print_translations' ) ) {
1463 $captured = $wp_scripts->print_translations( $handle, false );
1464 if ( is_string( $captured ) ) {
1465 $translations = $captured;
1466 }
1467 }
1468
1469 return array(
1470 'url' => $resolved,
1471 'before' => $before,
1472 'after' => $after,
1473 'l10n' => $l10n,
1474 'translations' => $translations,
1475 );
1476 }
1477
1478 /**
1479 * Resolves a registered style handle to its print-time URL + harvested
1480 * inline CSS, the styles-side mirror of
1481 * {@see openstation_resolve_script_payload()}.
1482 *
1483 * Why this exists: when a plugin's native window (or window-chrome
1484 * theme/control/slot/chrome) is activated mid-session — i.e. the user
1485 * activates the plugin from inside an open desktop shell — the parent
1486 * shell page already finished `wp_print_styles`. The plugin's
1487 * `admin_enqueue_scripts` callback never ran for it, so its
1488 * stylesheet is missing. The shell's lazy-loader fixes that by
1489 * injecting a `<link rel="stylesheet">` for every entry whose payload
1490 * carries a `styleUrl`.
1491 *
1492 * Captures both the resolved `src` and any `wp_add_inline_style()`
1493 * blobs attached to the handle so the shell can replay the same data
1494 * the print pipeline would have written.
1495 *
1496 * @param string $handle WP style handle.
1497 * @return array{ url:string, inline:string[] } Payload (empty `url` on miss).
1498 */
1499 function openstation_resolve_style_payload( $handle ) {
1500 $empty = array(
1501 'url' => '',
1502 'inline' => array(),
1503 );
1504
1505 $handle = (string) $handle;
1506 if ( '' === $handle ) {
1507 return $empty;
1508 }
1509 $wp_styles = wp_styles();
1510 if ( ! $wp_styles || ! isset( $wp_styles->registered[ $handle ] ) ) {
1511 return $empty;
1512 }
1513 $registered = $wp_styles->registered[ $handle ];
1514 $src = is_string( $registered->src ) ? $registered->src : '';
1515 if ( '' === $src ) {
1516 return $empty;
1517 }
1518
1519 // Normalize relative paths + attach cache-bust ver — same shape as
1520 // the script resolver. Keeps the two helpers symmetric so callers
1521 // don't have to special-case style vs script payloads.
1522 $resolved = $src;
1523 if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) {
1524 $resolved = site_url( $resolved );
1525 }
1526 if ( ! empty( $registered->ver ) ) {
1527 $resolved = add_query_arg( 'ver', $registered->ver, $resolved );
1528 }
1529
1530 // `wp_add_inline_style()` blobs land in `extra['after']` — capture
1531 // them so the shell can emit a `<style>` tag after the `<link>` to
1532 // preserve cascade order with what `WP_Styles::print_inline_style()`
1533 // would have written.
1534 $inline = array();
1535 if ( isset( $registered->extra['after'] ) && is_array( $registered->extra['after'] ) ) {
1536 foreach ( $registered->extra['after'] as $code ) {
1537 $code = (string) $code;
1538 if ( '' !== $code ) {
1539 $inline[] = $code;
1540 }
1541 }
1542 }
1543
1544 return array(
1545 'url' => $resolved,
1546 'inline' => $inline,
1547 );
1548 }
1549
1550 /**
1551 * Fire a `_doing_it_wrong()` notice exactly once per handle per
1552 * request. Shared by every `openstation_build_desktop_*_scripts_payload()`
1553 * caller — payload builders run on every shell-config rebuild
1554 * (multiple times per page load via REST + admin-bar refresh +
1555 * tests), so undeduped notices spam the error log AND trip
1556 * `expectedIncorrectUsage` assertions in unrelated tests.
1557 *
1558 * @param string $function_name `openstation_register_*_script` — passed verbatim to `_doing_it_wrong`.
1559 * @param string $kind Human label: `Command`, `Settings-tab`, `Title-bar button`.
1560 * @param string $handle Offending script handle.
1561 */
1562 function openstation_warn_unresolvable_script_handle( $function_name, $kind, $handle ) {
1563 static $warned = array();
1564 $cache_key = $function_name . '|' . $handle;
1565 if ( isset( $warned[ $cache_key ] ) ) {
1566 return;
1567 }
1568 $warned[ $cache_key ] = true;
1569
1570 if ( '__flush__' === $handle ) {
1571 // Test escape hatch: clear the dedupe cache so a flush
1572 // helper can reset between tests.
1573 $warned = array();
1574 return;
1575 }
1576
1577 _doing_it_wrong(
1578 esc_html( $function_name ),
1579 sprintf(
1580 /* translators: 1: kind ("Command"/"Settings-tab"/"Title-bar button"), 2: handle. */
1581 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' ),
1582 esc_html( $kind ),
1583 esc_html( $handle )
1584 ),
1585 '0.8.1'
1586 );
1587 }
1588
1589 /**
1590 * Test-only: clear every script-handle registry + the dedupe
1591 * cache for the unresolvable-handle notice. Tests call this in
1592 * `set_up` so prior tests' synthetic handles can't leak into
1593 * later assertions about payload shape.
1594 */
1595 function openstation_flush_script_handle_registries() {
1596 $flushers = array(
1597 'openstation_flush_desktop_command_script_registry',
1598 'openstation_flush_desktop_settings_tab_script_registry',
1599 'openstation_flush_dock_rail_renderer_script_registry',
1600 'openstation_flush_desktop_titlebar_button_script_registry',
1601 'openstation_flush_desktop_window_action_script_registry',
1602 'openstation_flush_desktop_unfocus_effect_script_registry',
1603 'openstation_flush_window_link_renderer_script_registry',
1604 'openstation_flush_window_theme_script_registry',
1605 'openstation_flush_window_theme_registry',
1606 'openstation_flush_window_control_script_registry',
1607 'openstation_flush_window_control_registry',
1608 'openstation_flush_window_slot_script_registry',
1609 'openstation_flush_window_slot_registry',
1610 'openstation_flush_window_chrome_script_registry',
1611 'openstation_flush_window_chrome_registry',
1612 'openstation_flush_window_notice_registry',
1613 );
1614
1615 foreach ( $flushers as $flusher ) {
1616 if ( function_exists( $flusher ) ) {
1617 $flusher();
1618 }
1619 }
1620
1621 openstation_warn_unresolvable_script_handle( '', '', '__flush__' );
1622 }
1623
1624 /**
1625 * Serialize the server-declared native-window registry into the
1626 * payload shape the shell consumes. For each entry registered via
1627 * `openstation_register_window()`, we capture: the window's
1628 * metadata (id/title/icon/placement/dimensions/autofocus), the
1629 * rendered template HTML (by running the template callback into an
1630 * output buffer), and the URL of the enqueued script handle (so
1631 * mid-session activations can load the plugin's JS dynamically
1632 * without a full shell reload).
1633 *
1634 * @return array[]
1635 */
1636 function openstation_build_native_windows_payload() {
1637 if ( ! function_exists( 'openstation_native_window_registry' ) ) {
1638 return array();
1639 }
1640 $registry = openstation_native_window_registry();
1641 if ( ! is_array( $registry ) ) {
1642 return array();
1643 }
1644
1645 $out = array();
1646 foreach ( $registry as $entry ) {
1647 if ( ! is_callable( $entry['template'] ) ) {
1648 continue;
1649 }
1650
1651 // Capture the template HTML (tab-wrapped when any
1652 // additional tabs are registered via
1653 // `openstation_register_window_tab()`; flat otherwise).
1654 // Captured as a string so the shell can inject it as a
1655 // `<template>` at mid-session plugin activation without a
1656 // reload.
1657 $template_html = openstation_build_native_window_template_html( $entry );
1658
1659 // Resolve script handle → full payload (URL + harvested
1660 // `extra` data) so the shell can inject a `<script>` tag
1661 // dynamically on mid-session activation WITHOUT dropping
1662 // `wp_localize_script` / `wp_add_inline_script` data the way
1663 // the bare `<script src>` lazy-load path would. See
1664 // `openstation_resolve_script_payload()` for shape.
1665 $script_handle = isset( $entry['script'] ) ? (string) $entry['script'] : '';
1666 $script_payload = openstation_resolve_script_payload( $script_handle );
1667
1668 // Companion handles (`scripts` arg) — bundles that extend the
1669 // window from outside it and must be in the tab before its
1670 // render callback paints. Same resolved shape as the main
1671 // script, kept as a list so the shell loads them in the
1672 // declared order ahead of it. Handles that resolve to nothing
1673 // (never registered) are dropped rather than shipped as an
1674 // entry the loader would skip anyway.
1675 $companion_scripts = array();
1676 if ( ! empty( $entry['scripts'] ) && is_array( $entry['scripts'] ) ) {
1677 foreach ( $entry['scripts'] as $companion_handle ) {
1678 $companion_handle = (string) $companion_handle;
1679 $companion_payload = openstation_resolve_script_payload( $companion_handle );
1680 if ( '' === $companion_payload['url'] ) {
1681 continue;
1682 }
1683 $companion_scripts[] = array(
1684 'scriptUrl' => $companion_payload['url'],
1685 'scriptHandle' => $companion_handle,
1686 'scriptBefore' => $companion_payload['before'],
1687 'scriptAfter' => $companion_payload['after'],
1688 'scriptL10n' => $companion_payload['l10n'],
1689 'scriptTranslations' => $companion_payload['translations'],
1690 );
1691 }
1692 }
1693
1694 // Resolve the optional style handle alongside the script so the
1695 // shell's lazy-loader can inject a `<link rel="stylesheet">`
1696 // (and any `wp_add_inline_style()` blobs) on mid-session
1697 // activation. Empty payload when no handle was declared OR the
1698 // handle isn't registered — both treated as "no styles to load."
1699 $style_handle = isset( $entry['style'] ) ? (string) $entry['style'] : '';
1700 $style_payload = openstation_resolve_style_payload( $style_handle );
1701
1702 // `config` arg on `openstation_register_window()` — discoverable
1703 // alternative to `wp_localize_script`. We synthesize a localize
1704 // snippet so it lands through the same delivery path as native
1705 // `wp_localize_script`. The bundle reads
1706 // `window.openStationWindowConfig[id]` (or via
1707 // `wp.os.getWindowConfig(id)`).
1708 if ( ! empty( $entry['config'] ) && is_array( $entry['config'] ) ) {
1709 $script_payload['l10n'][] = sprintf(
1710 'window.openStationWindowConfig=window.openStationWindowConfig||{};window.openStationWindowConfig[%s]=%s;',
1711 wp_json_encode( $entry['id'] ),
1712 wp_json_encode( $entry['config'] )
1713 );
1714 }
1715
1716 // Tab metadata (label + extra script payloads) ships alongside
1717 // the template so the shell can render a picker UI or load
1718 // additional tab scripts when a tab's activation is late.
1719 $tab_descriptors = array();
1720 if ( function_exists( 'openstation_get_native_window_tabs' ) ) {
1721 foreach ( openstation_get_native_window_tabs( $entry['id'] ) as $tab ) {
1722 // The resolver returns the empty payload shape itself
1723 // for an empty handle — no need to hand-write it here.
1724 $tab_payload = openstation_resolve_script_payload( $tab['script'] );
1725 $tab_descriptors[] = array(
1726 'value' => $tab['value'],
1727 'label' => $tab['label'],
1728 'isMain' => $tab['is_main'],
1729 'scriptUrl' => $tab_payload['url'],
1730 'scriptHandle' => $tab['script'],
1731 'scriptBefore' => $tab_payload['before'],
1732 'scriptAfter' => $tab_payload['after'],
1733 'scriptL10n' => $tab_payload['l10n'],
1734 'scriptTranslations' => $tab_payload['translations'],
1735 );
1736 }
1737 }
1738
1739 $out[] = array(
1740 'id' => $entry['id'],
1741 'title' => $entry['title'],
1742 'icon' => $entry['icon'],
1743 'placement' => $entry['placement'],
1744 // Sort key among system tiles. Absent / 0 puts a plugin's
1745 // launcher ahead of the shell's own trailing cluster.
1746 'dockOrder' => isset( $entry['dock_order'] ) ? (int) $entry['dock_order'] : 0,
1747 'placeable' => ! empty( $entry['placeable'] ),
1748 'width' => $entry['width'],
1749 'height' => $entry['height'],
1750 'minWidth' => $entry['min_width'],
1751 'minHeight' => $entry['min_height'],
1752 'autofocus' => $entry['autofocus'],
1753 'templateId' => 'os-native-window-' . $entry['id'],
1754 'templateHtml' => $template_html,
1755 'scriptUrl' => $script_payload['url'],
1756 'scriptHandle' => $script_handle,
1757 'ownerHandle' => $script_handle,
1758 'scriptBefore' => $script_payload['before'],
1759 'scriptAfter' => $script_payload['after'],
1760 'scriptL10n' => $script_payload['l10n'],
1761 'scriptTranslations' => $script_payload['translations'],
1762 'companionScripts' => $companion_scripts,
1763 // Whether the shell loads the bundle at boot rather than on
1764 // first open. Off by default: a window's script is dead
1765 // weight on every admin page until the window is actually
1766 // opened.
1767 'preloadScript' => ! empty( $entry['preload_script'] ),
1768 'styleUrl' => $style_payload['url'],
1769 'styleHandle' => $style_handle,
1770 'styleInline' => $style_payload['inline'],
1771 'tabs' => $tab_descriptors,
1772 );
1773 }
1774
1775 return $out;
1776 }
1777
1778 /**
1779 * Cleans a `$menu` / `$submenu` title for display.
1780 *
1781 * Strips badge spans first (`<span class="update-plugins count-3">`),
1782 * then any remaining markup. An empty result means the entry has no
1783 * usable label: plugins register `menu_title => null` to keep a page
1784 * reachable while hiding its row from classic admin's left menu, and
1785 * those must not become tabs.
1786 *
1787 * Shared so everything deciding "is this a visible tab?" agrees.
1788 * {@see openstation_chromeless_submenu_tab_urls()} hides an in-page
1789 * button on the strength of a tab existing, so a divergence here would
1790 * hide a button with nothing on screen to replace it.
1791 *
1792 * @param string $raw_title Raw `$menu[$i][0]` / `$submenu[$p][$i][0]` value.
1793 * @return string Cleaned title, empty when there is none.
1794 */
1795 function openstation_menu_item_title( $raw_title ) {
1796 $stripped = preg_replace( '/<span[^>]*>.*?<\/span>/s', '', (string) $raw_title );
1797
1798 return trim( wp_strip_all_tags( $stripped ) );
1799 }
1800
1801 /**
1802 * Determines whether a menu slug references a real file under `wp-admin/`.
1803 *
1804 * Mirrors the decision core's `wp-admin/menu-header.php` makes when
1805 * linking menu items: strip the query portion, then check whether the
1806 * remaining path exists inside `wp-admin/`. Two registered-slug shapes
1807 * hinge on this distinction:
1808 *
1809 * - URL-style slugs — ACF registers its top-level menu as
1810 * `edit.php?post_type=acf-field-group` via `add_menu_page()`. The
1811 * slug lands in `$_parent_pages`, but `edit.php` is a real admin
1812 * file: classic admin links it directly, and routing it through
1813 * `admin.php?page=…` makes core's dispatcher `wp_die()` with
1814 * "Cannot load edit.php?post_type=acf-field-group."
1815 * - Legacy file-path slugs — WP-Sweep registers
1816 * `wp-sweep/admin.php` via `add_management_page()`. No such file
1817 * exists under `wp-admin/`, so it must resolve as a plugin page
1818 * (`tools.php?page=wp-sweep/admin.php`).
1819 *
1820 * @param string $slug The raw menu item slug.
1821 * @return bool True when the query-stripped slug is a file under `wp-admin/`.
1822 */
1823 function openstation_is_admin_file_slug( $slug ) {
1824 $file = $slug;
1825 $pos = strpos( $file, '?' );
1826 if ( false !== $pos ) {
1827 $file = substr( $file, 0, $pos );
1828 }
1829
1830 if ( '' === $file || 0 !== validate_file( $file ) ) {
1831 return false;
1832 }
1833
1834 return file_exists( ABSPATH . 'wp-admin/' . $file );
1835 }
1836
1837 /**
1838 * Converts a menu item slug to a full admin URL.
1839 *
1840 * Handles three slug shapes:
1841 * 1. Direct file references (`edit.php`, `upload.php`) — passed
1842 * through `admin_url()` as-is.
1843 * 2. Plain plugin page slugs (`my-plugin`) — routed through
1844 * `admin.php?page=<slug>` with the slug `rawurlencode()`d.
1845 * 3. Plugin page slugs that embed extra query parameters
1846 * (`wc-admin&path=/customers`) — split on the first `&`, the
1847 * page portion is `rawurlencode()`d, the trailing query is
1848 * reparsed and reassembled with `add_query_arg()` so each
1849 * value is encoded once and the `&` separators are preserved.
1850 *
1851 * The third shape is unusual but legal — WordPress's
1852 * `add_submenu_page()` accepts a slug containing query
1853 * parameters and routes them through `admin.php`. WooCommerce
1854 * uses this pattern for every wc-admin React route
1855 * (`Customers`, `Analytics`, `Marketing`). Without the split
1856 * branch the entire string gets `rawurlencode()`d into the
1857 * `page` parameter, mangling `&` to `%26` and `=` to `%3D` —
1858 * WC's router never sees `path` and the page renders blank.
1859 *
1860 * Returns an `esc_url_raw()`-sanitized URL — these URLs flow
1861 * into the dock JS payload (JSON-encoded, then assigned to
1862 * `iframe.src` / `window.location.href`), not into HTML
1863 * attributes. Using `esc_url()` would emit `&#038;` for the `&`
1864 * separators, which the browser does NOT decode in JS string
1865 * contexts — the resulting iframe load would treat `&#038;path`
1866 * as a literal query key and miss the `path` parameter, sending
1867 * WC's router back to home instead of the requested route.
1868 *
1869 * @param string $slug The menu item slug or URL.
1870 * @return string The full admin URL, sanitized via `esc_url_raw()`.
1871 */
1872 function openstation_menu_item_url( $slug ) {
1873 // Already a full URL.
1874 if ( str_starts_with( $slug, 'http://' ) || str_starts_with( $slug, 'https://' ) ) {
1875 return esc_url_raw( $slug );
1876 }
1877
1878 // Strip path traversal sequences.
1879 $slug = str_replace( '..', '', $slug );
1880
1881 global $_parent_pages;
1882
1883 // Direct file reference (e.g., 'edit.php', 'upload.php') — but
1884 // NOT a registered plugin page that merely looks like one.
1885 // Legacy file-path slugs (WP-Sweep's 'wp-sweep/admin.php',
1886 // registered via add_management_page()) contain '.php' yet are
1887 // page slugs, not admin-root files; `$_parent_pages` is keyed by
1888 // the raw registered slug, so a hit there routes the slug to the
1889 // canonical resolver below (→ `tools.php?page=wp-sweep/admin.php`,
1890 // byte-identical to what core's menu_page_url() builds) instead
1891 // of a 404 at `admin_url( 'wp-sweep/admin.php' )`.
1892 //
1893 // The reverse also happens: URL-style slugs registered through
1894 // `add_menu_page()` / `add_submenu_page()` (ACF's
1895 // 'edit.php?post_type=acf-field-group') sit in `$_parent_pages`
1896 // too, yet reference a real `wp-admin/` file — those must stay
1897 // direct links, or core's `admin.php` dispatcher dies with
1898 // "Cannot load edit.php?post_type=acf-field-group." The admin-
1899 // file check wins over the registration check, same as classic
1900 // admin's `menu-header.php`.
1901 if (
1902 false !== strpos( $slug, '.php' ) &&
1903 ( ! isset( $_parent_pages[ $slug ] ) || openstation_is_admin_file_slug( $slug ) )
1904 ) {
1905 return esc_url_raw( admin_url( $slug ) );
1906 }
1907
1908 // Plugin page slug with embedded query parameters
1909 // (e.g., 'wc-admin&path=/customers'). Split the page slug from
1910 // the trailing args; we'll resolve the page slug below and
1911 // layer the args back on at the end. This avoids the naive
1912 // `rawurlencode()` packing the `&` separator into `%26`.
1913 $extra_args = array();
1914 if ( false !== strpos( $slug, '&' ) ) {
1915 list( $slug, $tail ) = array_pad( explode( '&', $slug, 2 ), 2, '' );
1916 if ( '' !== $tail ) {
1917 parse_str( $tail, $extra_args );
1918 }
1919 }
1920
1921 // Plain page slug — defer to WordPress's canonical resolver.
1922 //
1923 // `$_parent_pages` is the same global `menu_page_url()` reads;
1924 // we mirror its 4-line decision tree directly so we can return
1925 // a `esc_url_raw`-style raw URL (the `menu_page_url()` helper
1926 // runs its result through `esc_url()`, which entity-encodes the
1927 // `&` separators we need to keep raw for the downstream
1928 // `add_query_arg()` and the JS slug compare).
1929 //
1930 // Resolution rules, identical to core:
1931 // 1. Slug registered under a `.php` parent that itself isn't
1932 // a parent (Tools → `tools.php?page=…`, Settings →
1933 // `options-general.php?page=…`).
1934 // 2. Slug registered as a top-level menu, OR under a slug-
1935 // based parent (WC: `woocommerce` → `admin.php?page=…`).
1936 // 3. Slug not registered at all → fall back to `admin.php`
1937 // so the URL still targets a real dispatcher (matches the
1938 // pre-resolver behavior callers depended on).
1939 $host = 'admin.php?page=' . rawurlencode( $slug );
1940 if ( isset( $_parent_pages[ $slug ] ) ) {
1941 $parent_slug = $_parent_pages[ $slug ];
1942 if ( $parent_slug && ! isset( $_parent_pages[ $parent_slug ] ) ) {
1943 $host = add_query_arg( 'page', $slug, $parent_slug );
1944 }
1945 }
1946
1947 $url = admin_url( $host );
1948 if ( ! empty( $extra_args ) ) {
1949 $url = add_query_arg( $extra_args, $url );
1950 }
1951 return esc_url_raw( $url );
1952 }
1953