| 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.11.0 Rejected `data:` URIs outright (regression — see 0.18.x). |
| 253 |
* @since 0.18.x 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.27.0 |
| 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.27.0 |
| 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.27.0 |
| 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.27.0 |
| 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.27.0 |
| 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.27.0 |
| 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.27.0 |
| 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.27.0 |
| 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.27.0 |
| 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.27.0 |
| 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.27.0 |
| 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.27.0 |
| 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 |
return array( |
| 1136 |
'dockItems' => $dock, |
| 1137 |
'nativeWindows' => desktop_mode_build_native_windows_payload(), |
| 1138 |
'serverWidgets' => function_exists( 'desktop_mode_build_desktop_widgets_payload' ) |
| 1139 |
? desktop_mode_build_desktop_widgets_payload() |
| 1140 |
: array(), |
| 1141 |
'serverWallpapers' => function_exists( 'desktop_mode_build_desktop_wallpapers_payload' ) |
| 1142 |
? desktop_mode_build_desktop_wallpapers_payload() |
| 1143 |
: array(), |
| 1144 |
'serverCommandScripts' => function_exists( 'desktop_mode_build_desktop_command_scripts_payload' ) |
| 1145 |
? desktop_mode_build_desktop_command_scripts_payload() |
| 1146 |
: array(), |
| 1147 |
'serverCommands' => function_exists( 'desktop_mode_build_desktop_commands_payload' ) |
| 1148 |
? desktop_mode_build_desktop_commands_payload() |
| 1149 |
: array(), |
| 1150 |
'serverSettingsTabScripts' => function_exists( 'desktop_mode_build_desktop_settings_tab_scripts_payload' ) |
| 1151 |
? desktop_mode_build_desktop_settings_tab_scripts_payload() |
| 1152 |
: array(), |
| 1153 |
'serverSettingsTabs' => function_exists( 'desktop_mode_build_desktop_settings_tabs_payload' ) |
| 1154 |
? desktop_mode_build_desktop_settings_tabs_payload() |
| 1155 |
: array(), |
| 1156 |
'serverDockRailRendererScripts' => function_exists( 'desktop_mode_build_dock_rail_renderer_scripts_payload' ) |
| 1157 |
? desktop_mode_build_dock_rail_renderer_scripts_payload() |
| 1158 |
: array(), |
| 1159 |
'serverTitleBarButtonScripts' => function_exists( 'desktop_mode_build_desktop_titlebar_button_scripts_payload' ) |
| 1160 |
? desktop_mode_build_desktop_titlebar_button_scripts_payload() |
| 1161 |
: array(), |
| 1162 |
'serverWindowThemeScripts' => function_exists( 'desktop_mode_build_window_theme_scripts_payload' ) |
| 1163 |
? desktop_mode_build_window_theme_scripts_payload() |
| 1164 |
: array(), |
| 1165 |
'serverWindowThemes' => function_exists( 'desktop_mode_build_window_themes_payload' ) |
| 1166 |
? desktop_mode_build_window_themes_payload() |
| 1167 |
: array(), |
| 1168 |
'serverWindowControlScripts' => function_exists( 'desktop_mode_build_window_control_scripts_payload' ) |
| 1169 |
? desktop_mode_build_window_control_scripts_payload() |
| 1170 |
: array(), |
| 1171 |
'serverWindowControls' => function_exists( 'desktop_mode_build_window_controls_payload' ) |
| 1172 |
? desktop_mode_build_window_controls_payload() |
| 1173 |
: array(), |
| 1174 |
'serverWindowSlotScripts' => function_exists( 'desktop_mode_build_window_slot_scripts_payload' ) |
| 1175 |
? desktop_mode_build_window_slot_scripts_payload() |
| 1176 |
: array(), |
| 1177 |
'serverWindowSlots' => function_exists( 'desktop_mode_build_window_slots_payload' ) |
| 1178 |
? desktop_mode_build_window_slots_payload() |
| 1179 |
: array(), |
| 1180 |
'serverWindowChromeScripts' => function_exists( 'desktop_mode_build_window_chrome_scripts_payload' ) |
| 1181 |
? desktop_mode_build_window_chrome_scripts_payload() |
| 1182 |
: array(), |
| 1183 |
'serverWindowChromes' => function_exists( 'desktop_mode_build_window_chromes_payload' ) |
| 1184 |
? desktop_mode_build_window_chromes_payload() |
| 1185 |
: array(), |
| 1186 |
'serverWindowNotices' => function_exists( 'desktop_mode_build_window_notices_payload' ) |
| 1187 |
? desktop_mode_build_window_notices_payload() |
| 1188 |
: array(), |
| 1189 |
'desktopIcons' => function_exists( 'desktop_mode_build_desktop_icons_payload' ) |
| 1190 |
? desktop_mode_build_desktop_icons_payload() |
| 1191 |
: array(), |
| 1192 |
); |
| 1193 |
} |
| 1194 |
|
| 1195 |
/** |
| 1196 |
* Resolve a registered WP script handle into the full payload the |
| 1197 |
* shell needs to lazy-load it without going through `wp_print_scripts()`. |
| 1198 |
* |
| 1199 |
* Returns: |
| 1200 |
* |
| 1201 |
* ``` |
| 1202 |
* array( |
| 1203 |
* 'url' => 'https://…/script.js?ver=…', |
| 1204 |
* 'before' => array( /* `wp_add_inline_script( $h, $code, 'before' )` strings *\/ ), |
| 1205 |
* 'after' => array( /* `wp_add_inline_script( $h, $code, 'after' )` strings *\/ ), |
| 1206 |
* 'l10n' => array( /* `wp_localize_script( $h, $name, $data )` precomputed `<script>var $name = …;</script>` strings *\/ ), |
| 1207 |
* 'translations' => string, /* `wp_set_script_translations()` JED chunk *\/ |
| 1208 |
* ) |
| 1209 |
* ``` |
| 1210 |
* |
| 1211 |
* **The `l10n` / `before` / `after` / `translations` fields exist |
| 1212 |
* because the lazy-load path in the shell appends a raw |
| 1213 |
* `<script src="…">` and never invokes `wp_print_scripts()` — so any |
| 1214 |
* `wp_localize_script` / `wp_add_inline_script` / `wp_set_script_translations` |
| 1215 |
* data attached to the handle would be silently dropped without this |
| 1216 |
* harvest.** The shell injects each entry as inline `<script>` tags |
| 1217 |
* around the lazy `<script src>` in the same order |
| 1218 |
* `WP_Scripts::do_item()` would have used. |
| 1219 |
* |
| 1220 |
* Returns an empty payload (`array( 'url' => '' )`) when the handle |
| 1221 |
* is unregistered or has no source — callers treat that as "no |
| 1222 |
* script to load." |
| 1223 |
* |
| 1224 |
* Shared between `desktop_mode_register_window()` and |
| 1225 |
* `desktop_mode_register_widget()` (and every other registration that |
| 1226 |
* relies on lazy script loading in the shell) because all of them |
| 1227 |
* need identical handle→payload plumbing to power mid-session dynamic |
| 1228 |
* script loading without the `wp_print_scripts` lifecycle. |
| 1229 |
* |
| 1230 |
* @since 0.10.0 |
| 1231 |
* @since 0.6.0 Returns full payload (was `string` URL only). Renamed |
| 1232 |
* from `desktop_mode_resolve_script_url`. |
| 1233 |
* |
| 1234 |
* @param string $handle WP script handle. |
| 1235 |
* @return array{ url:string, before:string[], after:string[], l10n:string[], translations:string } Payload (empty `url` on miss). |
| 1236 |
*/ |
| 1237 |
function desktop_mode_resolve_script_payload( $handle ) { |
| 1238 |
$empty = array( |
| 1239 |
'url' => '', |
| 1240 |
'before' => array(), |
| 1241 |
'after' => array(), |
| 1242 |
'l10n' => array(), |
| 1243 |
'translations' => '', |
| 1244 |
); |
| 1245 |
|
| 1246 |
$handle = (string) $handle; |
| 1247 |
if ( '' === $handle ) { |
| 1248 |
return $empty; |
| 1249 |
} |
| 1250 |
$wp_scripts = wp_scripts(); |
| 1251 |
if ( ! $wp_scripts || ! isset( $wp_scripts->registered[ $handle ] ) ) { |
| 1252 |
return $empty; |
| 1253 |
} |
| 1254 |
$registered = $wp_scripts->registered[ $handle ]; |
| 1255 |
$src = is_string( $registered->src ) ? $registered->src : ''; |
| 1256 |
if ( '' === $src ) { |
| 1257 |
return $empty; |
| 1258 |
} |
| 1259 |
|
| 1260 |
// Normalize relative paths + attach cache-bust ver. |
| 1261 |
$resolved = $src; |
| 1262 |
if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) { |
| 1263 |
$resolved = site_url( $resolved ); |
| 1264 |
} |
| 1265 |
if ( ! empty( $registered->ver ) ) { |
| 1266 |
$resolved = add_query_arg( 'ver', $registered->ver, $resolved ); |
| 1267 |
} |
| 1268 |
|
| 1269 |
// Harvest `extra` data the lazy-load path would otherwise drop. |
| 1270 |
$before = array(); |
| 1271 |
$after = array(); |
| 1272 |
$l10n = array(); |
| 1273 |
|
| 1274 |
if ( isset( $registered->extra['before'] ) && is_array( $registered->extra['before'] ) ) { |
| 1275 |
foreach ( $registered->extra['before'] as $code ) { |
| 1276 |
$code = (string) $code; |
| 1277 |
if ( '' !== $code ) { |
| 1278 |
$before[] = $code; |
| 1279 |
} |
| 1280 |
} |
| 1281 |
} |
| 1282 |
if ( isset( $registered->extra['after'] ) && is_array( $registered->extra['after'] ) ) { |
| 1283 |
foreach ( $registered->extra['after'] as $code ) { |
| 1284 |
$code = (string) $code; |
| 1285 |
if ( '' !== $code ) { |
| 1286 |
$after[] = $code; |
| 1287 |
} |
| 1288 |
} |
| 1289 |
} |
| 1290 |
// `wp_localize_script` stores its JS at `extra['data']` as a single |
| 1291 |
// concatenated string of `var x = …;` assignments. We capture it |
| 1292 |
// verbatim — the shell will eval it as the body of an inline |
| 1293 |
// `<script>` tag, mirroring what `WP_Scripts::print_extra_script()` |
| 1294 |
// does at print time. |
| 1295 |
if ( ! empty( $registered->extra['data'] ) && is_string( $registered->extra['data'] ) ) { |
| 1296 |
$l10n[] = $registered->extra['data']; |
| 1297 |
} |
| 1298 |
|
| 1299 |
// Translations chunk — `wp_set_script_translations()` builds a |
| 1300 |
// `wp.i18n.setLocaleData( JSON, 'domain' )` snippet that the print |
| 1301 |
// pipeline emits before the script body. `print_translations( |
| 1302 |
// $handle, false )` returns the snippet without echoing. |
| 1303 |
$translations = ''; |
| 1304 |
if ( method_exists( $wp_scripts, 'print_translations' ) ) { |
| 1305 |
$captured = $wp_scripts->print_translations( $handle, false ); |
| 1306 |
if ( is_string( $captured ) ) { |
| 1307 |
$translations = $captured; |
| 1308 |
} |
| 1309 |
} |
| 1310 |
|
| 1311 |
return array( |
| 1312 |
'url' => $resolved, |
| 1313 |
'before' => $before, |
| 1314 |
'after' => $after, |
| 1315 |
'l10n' => $l10n, |
| 1316 |
'translations' => $translations, |
| 1317 |
); |
| 1318 |
} |
| 1319 |
|
| 1320 |
/** |
| 1321 |
* Resolves a registered style handle to its print-time URL + harvested |
| 1322 |
* inline CSS, the styles-side mirror of |
| 1323 |
* {@see desktop_mode_resolve_script_payload()}. |
| 1324 |
* |
| 1325 |
* Why this exists: when a plugin's native window (or window-chrome |
| 1326 |
* theme/control/slot/chrome) is activated mid-session — i.e. the user |
| 1327 |
* activates the plugin from inside an open desktop shell — the parent |
| 1328 |
* shell page already finished `wp_print_styles`. The plugin's |
| 1329 |
* `admin_enqueue_scripts` callback never ran for it, so its |
| 1330 |
* stylesheet is missing. The shell's lazy-loader fixes that by |
| 1331 |
* injecting a `<link rel="stylesheet">` for every entry whose payload |
| 1332 |
* carries a `styleUrl`. |
| 1333 |
* |
| 1334 |
* Captures both the resolved `src` and any `wp_add_inline_style()` |
| 1335 |
* blobs attached to the handle so the shell can replay the same data |
| 1336 |
* the print pipeline would have written. |
| 1337 |
* |
| 1338 |
* @since 0.18.1 |
| 1339 |
* |
| 1340 |
* @param string $handle WP style handle. |
| 1341 |
* @return array{ url:string, inline:string[] } Payload (empty `url` on miss). |
| 1342 |
*/ |
| 1343 |
function desktop_mode_resolve_style_payload( $handle ) { |
| 1344 |
$empty = array( |
| 1345 |
'url' => '', |
| 1346 |
'inline' => array(), |
| 1347 |
); |
| 1348 |
|
| 1349 |
$handle = (string) $handle; |
| 1350 |
if ( '' === $handle ) { |
| 1351 |
return $empty; |
| 1352 |
} |
| 1353 |
$wp_styles = wp_styles(); |
| 1354 |
if ( ! $wp_styles || ! isset( $wp_styles->registered[ $handle ] ) ) { |
| 1355 |
return $empty; |
| 1356 |
} |
| 1357 |
$registered = $wp_styles->registered[ $handle ]; |
| 1358 |
$src = is_string( $registered->src ) ? $registered->src : ''; |
| 1359 |
if ( '' === $src ) { |
| 1360 |
return $empty; |
| 1361 |
} |
| 1362 |
|
| 1363 |
// Normalize relative paths + attach cache-bust ver — same shape as |
| 1364 |
// the script resolver. Keeps the two helpers symmetric so callers |
| 1365 |
// don't have to special-case style vs script payloads. |
| 1366 |
$resolved = $src; |
| 1367 |
if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) { |
| 1368 |
$resolved = site_url( $resolved ); |
| 1369 |
} |
| 1370 |
if ( ! empty( $registered->ver ) ) { |
| 1371 |
$resolved = add_query_arg( 'ver', $registered->ver, $resolved ); |
| 1372 |
} |
| 1373 |
|
| 1374 |
// `wp_add_inline_style()` blobs land in `extra['after']` — capture |
| 1375 |
// them so the shell can emit a `<style>` tag after the `<link>` to |
| 1376 |
// preserve cascade order with what `WP_Styles::print_inline_style()` |
| 1377 |
// would have written. |
| 1378 |
$inline = array(); |
| 1379 |
if ( isset( $registered->extra['after'] ) && is_array( $registered->extra['after'] ) ) { |
| 1380 |
foreach ( $registered->extra['after'] as $code ) { |
| 1381 |
$code = (string) $code; |
| 1382 |
if ( '' !== $code ) { |
| 1383 |
$inline[] = $code; |
| 1384 |
} |
| 1385 |
} |
| 1386 |
} |
| 1387 |
|
| 1388 |
return array( |
| 1389 |
'url' => $resolved, |
| 1390 |
'inline' => $inline, |
| 1391 |
); |
| 1392 |
} |
| 1393 |
|
| 1394 |
/** |
| 1395 |
* Fire a `_doing_it_wrong()` notice exactly once per handle per |
| 1396 |
* request. Shared by every `desktop_mode_build_desktop_*_scripts_payload()` |
| 1397 |
* caller — payload builders run on every shell-config rebuild |
| 1398 |
* (multiple times per page load via REST + admin-bar refresh + |
| 1399 |
* tests), so undeduped notices spam the error log AND trip |
| 1400 |
* `expectedIncorrectUsage` assertions in unrelated tests. |
| 1401 |
* |
| 1402 |
* @since 0.18.0 |
| 1403 |
* |
| 1404 |
* @param string $function_name `desktop_mode_register_*_script` — passed verbatim to `_doing_it_wrong`. |
| 1405 |
* @param string $kind Human label: `Command`, `Settings-tab`, `Title-bar button`. |
| 1406 |
* @param string $handle Offending script handle. |
| 1407 |
*/ |
| 1408 |
function desktop_mode_warn_unresolvable_script_handle( $function_name, $kind, $handle ) { |
| 1409 |
static $warned = array(); |
| 1410 |
$cache_key = $function_name . '|' . $handle; |
| 1411 |
if ( isset( $warned[ $cache_key ] ) ) { |
| 1412 |
return; |
| 1413 |
} |
| 1414 |
$warned[ $cache_key ] = true; |
| 1415 |
|
| 1416 |
if ( '__flush__' === $handle ) { |
| 1417 |
// Test escape hatch: clear the dedupe cache so a flush |
| 1418 |
// helper can reset between tests. |
| 1419 |
$warned = array(); |
| 1420 |
return; |
| 1421 |
} |
| 1422 |
|
| 1423 |
_doing_it_wrong( |
| 1424 |
esc_html( $function_name ), |
| 1425 |
sprintf( |
| 1426 |
/* translators: 1: kind ("Command"/"Settings-tab"/"Title-bar button"), 2: handle. */ |
| 1427 |
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' ), |
| 1428 |
esc_html( $kind ), |
| 1429 |
esc_html( $handle ) |
| 1430 |
), |
| 1431 |
'0.18.0' |
| 1432 |
); |
| 1433 |
} |
| 1434 |
|
| 1435 |
/** |
| 1436 |
* Test-only: clear every script-handle registry + the dedupe |
| 1437 |
* cache for the unresolvable-handle notice. Tests call this in |
| 1438 |
* `set_up` so prior tests' synthetic handles can't leak into |
| 1439 |
* later assertions about payload shape. |
| 1440 |
* |
| 1441 |
* @since 0.18.0 |
| 1442 |
*/ |
| 1443 |
function desktop_mode_flush_script_handle_registries() { |
| 1444 |
if ( function_exists( 'desktop_mode_flush_desktop_command_script_registry' ) ) { |
| 1445 |
desktop_mode_flush_desktop_command_script_registry(); |
| 1446 |
} |
| 1447 |
if ( function_exists( 'desktop_mode_flush_desktop_settings_tab_script_registry' ) ) { |
| 1448 |
desktop_mode_flush_desktop_settings_tab_script_registry(); |
| 1449 |
} |
| 1450 |
if ( function_exists( 'desktop_mode_flush_desktop_titlebar_button_script_registry' ) ) { |
| 1451 |
desktop_mode_flush_desktop_titlebar_button_script_registry(); |
| 1452 |
} |
| 1453 |
if ( function_exists( 'desktop_mode_flush_window_theme_script_registry' ) ) { |
| 1454 |
desktop_mode_flush_window_theme_script_registry(); |
| 1455 |
} |
| 1456 |
if ( function_exists( 'desktop_mode_flush_window_theme_registry' ) ) { |
| 1457 |
desktop_mode_flush_window_theme_registry(); |
| 1458 |
} |
| 1459 |
if ( function_exists( 'desktop_mode_flush_window_control_script_registry' ) ) { |
| 1460 |
desktop_mode_flush_window_control_script_registry(); |
| 1461 |
} |
| 1462 |
if ( function_exists( 'desktop_mode_flush_window_control_registry' ) ) { |
| 1463 |
desktop_mode_flush_window_control_registry(); |
| 1464 |
} |
| 1465 |
if ( function_exists( 'desktop_mode_flush_window_slot_script_registry' ) ) { |
| 1466 |
desktop_mode_flush_window_slot_script_registry(); |
| 1467 |
} |
| 1468 |
if ( function_exists( 'desktop_mode_flush_window_slot_registry' ) ) { |
| 1469 |
desktop_mode_flush_window_slot_registry(); |
| 1470 |
} |
| 1471 |
if ( function_exists( 'desktop_mode_flush_window_chrome_script_registry' ) ) { |
| 1472 |
desktop_mode_flush_window_chrome_script_registry(); |
| 1473 |
} |
| 1474 |
if ( function_exists( 'desktop_mode_flush_window_chrome_registry' ) ) { |
| 1475 |
desktop_mode_flush_window_chrome_registry(); |
| 1476 |
} |
| 1477 |
if ( function_exists( 'desktop_mode_flush_window_notice_registry' ) ) { |
| 1478 |
desktop_mode_flush_window_notice_registry(); |
| 1479 |
} |
| 1480 |
desktop_mode_warn_unresolvable_script_handle( '', '', '__flush__' ); |
| 1481 |
} |
| 1482 |
|
| 1483 |
/** |
| 1484 |
* Serialize the server-declared native-window registry into the |
| 1485 |
* payload shape the shell consumes. For each entry registered via |
| 1486 |
* `desktop_mode_register_window()`, we capture: the window's |
| 1487 |
* metadata (id/title/icon/placement/dimensions/autofocus), the |
| 1488 |
* rendered template HTML (by running the template callback into an |
| 1489 |
* output buffer), and the URL of the enqueued script handle (so |
| 1490 |
* mid-session activations can load the plugin's JS dynamically |
| 1491 |
* without a full shell reload). |
| 1492 |
* |
| 1493 |
* @since 0.10.0 |
| 1494 |
* |
| 1495 |
* @return array[] |
| 1496 |
*/ |
| 1497 |
function desktop_mode_build_native_windows_payload() { |
| 1498 |
if ( ! function_exists( 'desktop_mode_native_window_registry' ) ) { |
| 1499 |
return array(); |
| 1500 |
} |
| 1501 |
$registry = desktop_mode_native_window_registry(); |
| 1502 |
if ( ! is_array( $registry ) ) { |
| 1503 |
return array(); |
| 1504 |
} |
| 1505 |
|
| 1506 |
$out = array(); |
| 1507 |
foreach ( $registry as $entry ) { |
| 1508 |
if ( ! is_callable( $entry['template'] ) ) { |
| 1509 |
continue; |
| 1510 |
} |
| 1511 |
|
| 1512 |
// Capture the template HTML (tab-wrapped when any |
| 1513 |
// additional tabs are registered via |
| 1514 |
// `desktop_mode_register_window_tab()`; flat otherwise). |
| 1515 |
// Captured as a string so the shell can inject it as a |
| 1516 |
// `<template>` at mid-session plugin activation without a |
| 1517 |
// reload. |
| 1518 |
$template_html = desktop_mode_build_native_window_template_html( $entry ); |
| 1519 |
|
| 1520 |
// Resolve script handle → full payload (URL + harvested |
| 1521 |
// `extra` data) so the shell can inject a `<script>` tag |
| 1522 |
// dynamically on mid-session activation WITHOUT dropping |
| 1523 |
// `wp_localize_script` / `wp_add_inline_script` data the way |
| 1524 |
// the bare `<script src>` lazy-load path would. See |
| 1525 |
// `desktop_mode_resolve_script_payload()` for shape. |
| 1526 |
$script_handle = isset( $entry['script'] ) ? (string) $entry['script'] : ''; |
| 1527 |
$script_payload = desktop_mode_resolve_script_payload( $script_handle ); |
| 1528 |
|
| 1529 |
// Resolve the optional style handle alongside the script so the |
| 1530 |
// shell's lazy-loader can inject a `<link rel="stylesheet">` |
| 1531 |
// (and any `wp_add_inline_style()` blobs) on mid-session |
| 1532 |
// activation. Empty payload when no handle was declared OR the |
| 1533 |
// handle isn't registered — both treated as "no styles to load." |
| 1534 |
$style_handle = isset( $entry['style'] ) ? (string) $entry['style'] : ''; |
| 1535 |
$style_payload = desktop_mode_resolve_style_payload( $style_handle ); |
| 1536 |
|
| 1537 |
// `config` arg on `desktop_mode_register_window()` — discoverable |
| 1538 |
// alternative to `wp_localize_script`. We synthesize a localize |
| 1539 |
// snippet so it lands through the same delivery path as native |
| 1540 |
// `wp_localize_script`. The bundle reads |
| 1541 |
// `window.desktopModeWindowConfig[id]` (or via |
| 1542 |
// `wp.desktop.getWindowConfig(id)`). |
| 1543 |
if ( ! empty( $entry['config'] ) && is_array( $entry['config'] ) ) { |
| 1544 |
$script_payload['l10n'][] = sprintf( |
| 1545 |
'window.desktopModeWindowConfig=window.desktopModeWindowConfig||{};window.desktopModeWindowConfig[%s]=%s;', |
| 1546 |
wp_json_encode( $entry['id'] ), |
| 1547 |
wp_json_encode( $entry['config'] ) |
| 1548 |
); |
| 1549 |
} |
| 1550 |
|
| 1551 |
// Tab metadata (label + extra script payloads) ships alongside |
| 1552 |
// the template so the shell can render a picker UI or load |
| 1553 |
// additional tab scripts when a tab's activation is late. |
| 1554 |
$tab_descriptors = array(); |
| 1555 |
if ( function_exists( 'desktop_mode_get_native_window_tabs' ) ) { |
| 1556 |
foreach ( desktop_mode_get_native_window_tabs( $entry['id'] ) as $tab ) { |
| 1557 |
$tab_payload = '' !== $tab['script'] |
| 1558 |
? desktop_mode_resolve_script_payload( $tab['script'] ) |
| 1559 |
: array( |
| 1560 |
'url' => '', |
| 1561 |
'before' => array(), |
| 1562 |
'after' => array(), |
| 1563 |
'l10n' => array(), |
| 1564 |
'translations' => '', |
| 1565 |
); |
| 1566 |
$tab_descriptors[] = array( |
| 1567 |
'value' => $tab['value'], |
| 1568 |
'label' => $tab['label'], |
| 1569 |
'isMain' => $tab['is_main'], |
| 1570 |
'scriptUrl' => $tab_payload['url'], |
| 1571 |
'scriptHandle' => $tab['script'], |
| 1572 |
'scriptBefore' => $tab_payload['before'], |
| 1573 |
'scriptAfter' => $tab_payload['after'], |
| 1574 |
'scriptL10n' => $tab_payload['l10n'], |
| 1575 |
'scriptTranslations' => $tab_payload['translations'], |
| 1576 |
); |
| 1577 |
} |
| 1578 |
} |
| 1579 |
|
| 1580 |
$out[] = array( |
| 1581 |
'id' => $entry['id'], |
| 1582 |
'title' => $entry['title'], |
| 1583 |
'icon' => $entry['icon'], |
| 1584 |
'placement' => $entry['placement'], |
| 1585 |
'width' => $entry['width'], |
| 1586 |
'height' => $entry['height'], |
| 1587 |
'minWidth' => $entry['min_width'], |
| 1588 |
'minHeight' => $entry['min_height'], |
| 1589 |
'autofocus' => $entry['autofocus'], |
| 1590 |
'templateId' => 'desktop-mode-native-window-' . $entry['id'], |
| 1591 |
'templateHtml' => $template_html, |
| 1592 |
'scriptUrl' => $script_payload['url'], |
| 1593 |
'scriptHandle' => $script_handle, |
| 1594 |
'ownerHandle' => $script_handle, |
| 1595 |
'scriptBefore' => $script_payload['before'], |
| 1596 |
'scriptAfter' => $script_payload['after'], |
| 1597 |
'scriptL10n' => $script_payload['l10n'], |
| 1598 |
'scriptTranslations' => $script_payload['translations'], |
| 1599 |
'styleUrl' => $style_payload['url'], |
| 1600 |
'styleHandle' => $style_handle, |
| 1601 |
'styleInline' => $style_payload['inline'], |
| 1602 |
'tabs' => $tab_descriptors, |
| 1603 |
); |
| 1604 |
} |
| 1605 |
|
| 1606 |
return $out; |
| 1607 |
} |
| 1608 |
|
| 1609 |
/** |
| 1610 |
* Converts a menu item slug to a full admin URL. |
| 1611 |
* |
| 1612 |
* Handles three slug shapes: |
| 1613 |
* 1. Direct file references (`edit.php`, `upload.php`) — passed |
| 1614 |
* through `admin_url()` as-is. |
| 1615 |
* 2. Plain plugin page slugs (`my-plugin`) — routed through |
| 1616 |
* `admin.php?page=<slug>` with the slug `rawurlencode()`d. |
| 1617 |
* 3. Plugin page slugs that embed extra query parameters |
| 1618 |
* (`wc-admin&path=/customers`) — split on the first `&`, the |
| 1619 |
* page portion is `rawurlencode()`d, the trailing query is |
| 1620 |
* reparsed and reassembled with `add_query_arg()` so each |
| 1621 |
* value is encoded once and the `&` separators are preserved. |
| 1622 |
* |
| 1623 |
* The third shape is unusual but legal — WordPress's |
| 1624 |
* `add_submenu_page()` accepts a slug containing query |
| 1625 |
* parameters and routes them through `admin.php`. WooCommerce |
| 1626 |
* uses this pattern for every wc-admin React route |
| 1627 |
* (`Customers`, `Analytics`, `Marketing`). Without the split |
| 1628 |
* branch the entire string gets `rawurlencode()`d into the |
| 1629 |
* `page` parameter, mangling `&` to `%26` and `=` to `%3D` — |
| 1630 |
* WC's router never sees `path` and the page renders blank. |
| 1631 |
* |
| 1632 |
* Returns an `esc_url_raw()`-sanitized URL — these URLs flow |
| 1633 |
* into the dock JS payload (JSON-encoded, then assigned to |
| 1634 |
* `iframe.src` / `window.location.href`), not into HTML |
| 1635 |
* attributes. Using `esc_url()` would emit `&` for the `&` |
| 1636 |
* separators, which the browser does NOT decode in JS string |
| 1637 |
* contexts — the resulting iframe load would treat `&path` |
| 1638 |
* as a literal query key and miss the `path` parameter, sending |
| 1639 |
* WC's router back to home instead of the requested route. |
| 1640 |
* |
| 1641 |
* @since 0.1.0 |
| 1642 |
* |
| 1643 |
* @param string $slug The menu item slug or URL. |
| 1644 |
* @return string The full admin URL, sanitized via `esc_url_raw()`. |
| 1645 |
*/ |
| 1646 |
function desktop_mode_menu_item_url( $slug ) { |
| 1647 |
// Already a full URL. |
| 1648 |
if ( str_starts_with( $slug, 'http://' ) || str_starts_with( $slug, 'https://' ) ) { |
| 1649 |
return esc_url_raw( $slug ); |
| 1650 |
} |
| 1651 |
|
| 1652 |
// Strip path traversal sequences. |
| 1653 |
$slug = str_replace( '..', '', $slug ); |
| 1654 |
|
| 1655 |
// Direct file reference (e.g., 'edit.php', 'upload.php'). |
| 1656 |
if ( false !== strpos( $slug, '.php' ) ) { |
| 1657 |
return esc_url_raw( admin_url( $slug ) ); |
| 1658 |
} |
| 1659 |
|
| 1660 |
// Plugin page slug with embedded query parameters |
| 1661 |
// (e.g., 'wc-admin&path=/customers'). Split the page slug from |
| 1662 |
// the trailing args; we'll resolve the page slug below and |
| 1663 |
// layer the args back on at the end. This avoids the naive |
| 1664 |
// `rawurlencode()` packing the `&` separator into `%26`. |
| 1665 |
$extra_args = array(); |
| 1666 |
if ( false !== strpos( $slug, '&' ) ) { |
| 1667 |
list( $slug, $tail ) = array_pad( explode( '&', $slug, 2 ), 2, '' ); |
| 1668 |
if ( '' !== $tail ) { |
| 1669 |
parse_str( $tail, $extra_args ); |
| 1670 |
} |
| 1671 |
} |
| 1672 |
|
| 1673 |
// Plain page slug — defer to WordPress's canonical resolver. |
| 1674 |
// |
| 1675 |
// `$_parent_pages` is the same global `menu_page_url()` reads; |
| 1676 |
// we mirror its 4-line decision tree directly so we can return |
| 1677 |
// a `esc_url_raw`-style raw URL (the `menu_page_url()` helper |
| 1678 |
// runs its result through `esc_url()`, which entity-encodes the |
| 1679 |
// `&` separators we need to keep raw for the downstream |
| 1680 |
// `add_query_arg()` and the JS slug compare). |
| 1681 |
// |
| 1682 |
// Resolution rules, identical to core: |
| 1683 |
// 1. Slug registered under a `.php` parent that itself isn't |
| 1684 |
// a parent (Tools → `tools.php?page=…`, Settings → |
| 1685 |
// `options-general.php?page=…`). |
| 1686 |
// 2. Slug registered as a top-level menu, OR under a slug- |
| 1687 |
// based parent (WC: `woocommerce` → `admin.php?page=…`). |
| 1688 |
// 3. Slug not registered at all → fall back to `admin.php` |
| 1689 |
// so the URL still targets a real dispatcher (matches the |
| 1690 |
// pre-resolver behavior callers depended on). |
| 1691 |
global $_parent_pages; |
| 1692 |
$host = 'admin.php?page=' . rawurlencode( $slug ); |
| 1693 |
if ( isset( $_parent_pages[ $slug ] ) ) { |
| 1694 |
$parent_slug = $_parent_pages[ $slug ]; |
| 1695 |
if ( $parent_slug && ! isset( $_parent_pages[ $parent_slug ] ) ) { |
| 1696 |
$host = add_query_arg( 'page', $slug, $parent_slug ); |
| 1697 |
} |
| 1698 |
} |
| 1699 |
|
| 1700 |
$url = admin_url( $host ); |
| 1701 |
if ( ! empty( $extra_args ) ) { |
| 1702 |
$url = add_query_arg( $extra_args, $url ); |
| 1703 |
} |
| 1704 |
return esc_url_raw( $url ); |
| 1705 |
} |
| 1706 |
|