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

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