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

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