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

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