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

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